├── .gitignore ├── LICENSE.txt ├── MANIFEST.in ├── README.md ├── certbot_regru ├── __init__.py ├── dns.py └── dns_test.py ├── regru.ini ├── setup.cfg └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Project settings 86 | .idea 87 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2018 Max Pryakhin 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.txt 2 | include README.rst 3 | include regru.ini 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # certbot-regru 2 | Reg.ru DNS authenticator plugin for Certbot 3 | 4 | An authenticator plugin for [certbot](https://certbot.eff.org/) to support [Let's Encrypt](https://letsencrypt.org/) 5 | DNS challenges (dns-01) for domains managed by the nameservers of [Reg.ru](https://www.reg.ru). 6 | 7 | ## Requirements 8 | * certbot (>=0.21.1) 9 | 10 | For older Ubuntu distributions check out this PPA: 11 | [ppa:certbot/certbot](https://launchpad.net/~certbot/+archive/ubuntu/certbot) 12 | 13 | ## Installation 14 | 1. First install the plugin: 15 | ``` 16 | sudo pip install certbot-regru 17 | ``` 18 | 19 | 2. Configure it with your Reg.ru Credentials: 20 | ``` 21 | sudo vim /etc/letsencrypt/regru.ini 22 | ``` 23 | 24 | 3. Make sure the file is only readable by root! Otherwise all your domains might be in danger: 25 | ``` 26 | sudo chmod 0600 /etc/letsencrypt/regru.ini 27 | ``` 28 | 29 | ## Usage 30 | Request new certificates via a certbot invocation like this: 31 | 32 | sudo certbot certonly -a certbot-regru:dns -d sub.domain.tld -d *.wildcard.tld 33 | 34 | Renewals will automatically be performed using the same authenticator and credentials by certbot. 35 | 36 | ## Command Line Options 37 | ``` 38 | --certbot-regru:dns-propagation-seconds PROPAGATION_SECONDS 39 | The number of seconds to wait for DNS to propagate 40 | before asking the ACME server to verify the DNS record. 41 | (default: 120) 42 | --certbot-regru:dns-credentials PATH_TO_CREDENTIALS 43 | Path to Reg.ru account credentials INI file 44 | (default: /etc/letsencrypt/regru.ini) 45 | 46 | ``` 47 | 48 | See also `certbot --help certbot-regru:dns` for further information. 49 | 50 | ## Removal 51 | ``` 52 | sudo pip uninstall certbot-regru 53 | ``` 54 | -------------------------------------------------------------------------------- /certbot_regru/__init__.py: -------------------------------------------------------------------------------- 1 | """Reg.ru DNS authenticator plugin for Certbot""" 2 | __version__ = '1.0.2' 3 | -------------------------------------------------------------------------------- /certbot_regru/dns.py: -------------------------------------------------------------------------------- 1 | """DNS Authenticator for Reg.ru DNS.""" 2 | import logging 3 | 4 | import json 5 | import requests 6 | 7 | import zope.interface 8 | 9 | from certbot import errors 10 | from certbot import interfaces 11 | from certbot.plugins import dns_common 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | @zope.interface.implementer(interfaces.IAuthenticator) 17 | @zope.interface.provider(interfaces.IPluginFactory) 18 | class Authenticator(dns_common.DNSAuthenticator): 19 | """DNS Authenticator for Reg.ru DNS 20 | 21 | This Authenticator uses the Reg.ru DNS API to fulfill a dns-01 challenge. 22 | """ 23 | 24 | description = 'Obtain certificates using a DNS TXT record (if you are using Reg.ru for DNS).' 25 | 26 | def __init__(self, *args, **kwargs): 27 | super(Authenticator, self).__init__(*args, **kwargs) 28 | self.credentials = None 29 | 30 | @classmethod 31 | def add_parser_arguments(cls, add): # pylint: disable=arguments-differ 32 | super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=120) 33 | add('credentials', help='Path to Reg.ru credentials INI file', default='/etc/letsencrypt/regru.ini') 34 | 35 | def more_info(self): # pylint: disable=missing-docstring,no-self-use 36 | return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 37 | 'the Reg.ru API.' 38 | 39 | def _setup_credentials(self): 40 | self.credentials = self._configure_credentials( 41 | 'credentials', 42 | 'path to Reg.ru credentials INI file', 43 | { 44 | 'username': 'Username of the Reg.ru account.', 45 | 'password': 'Password of the Reg.ru account.', 46 | } 47 | ) 48 | 49 | def _perform(self, domain, validation_name, validation): 50 | self._get_regru_client().add_txt_record(validation_name, validation) 51 | 52 | def _cleanup(self, domain, validation_name, validation): 53 | self._get_regru_client().del_txt_record(validation_name, validation) 54 | 55 | def _get_regru_client(self): 56 | return _RegRuClient(self.credentials.conf('username'), self.credentials.conf('password')) 57 | 58 | 59 | class _RegRuClient(object): 60 | """ 61 | Encapsulates all communication with the Reg.ru 62 | """ 63 | 64 | def __init__(self, username, password): 65 | self.http = _HttpClient() 66 | self.options = { 67 | 'username': username, 68 | 'password': password, 69 | 'io_encoding': 'utf8', 70 | 'show_input_params': 1, 71 | 'output_format': 'json', 72 | 'input_format': 'json', 73 | } 74 | 75 | def add_txt_record(self, record_name, record_content): 76 | """ 77 | Add a TXT record using the supplied information. 78 | :param str record_name: The record name (typically beginning with '_acme-challenge.'). 79 | :param str record_content: The record content (typically the challenge validation). 80 | :raises certbot.errors.PluginError: if an error occurs communicating with the Reg.ru API 81 | """ 82 | 83 | data = self._create_params(record_name, {'text': record_content}) 84 | 85 | try: 86 | logger.debug('Attempting to add record: %s', data) 87 | response = self.http.send('https://api.reg.ru/api/regru2/zone/add_txt', data) 88 | except requests.exceptions.RequestException as e: 89 | logger.error('Encountered error adding TXT record: %d %s', e, e) 90 | raise errors.PluginError('Error communicating with the Reg.ru API: {0}'.format(e)) 91 | 92 | if 'result' not in response or response['result'] != 'success': 93 | logger.error('Encountered error adding TXT record: %s', response) 94 | raise errors.PluginError('Error communicating with the Reg.ru API: {0}'.format(response)) 95 | 96 | logger.debug('Successfully added TXT record') 97 | 98 | def del_txt_record(self, record_name, record_content): 99 | """ 100 | Delete a TXT record using the supplied information. 101 | Note that both the record's name and content are used to ensure that similar records 102 | created concurrently (e.g., due to concurrent invocations of this plugin) are not deleted. 103 | Failures are logged, but not raised. 104 | :param str record_name: The record name (typically beginning with '_acme-challenge.'). 105 | :param str record_content: The record content (typically the challenge validation). 106 | """ 107 | 108 | data = self._create_params(record_name, { 109 | 'record_type': 'TXT', 110 | 'content': record_content 111 | }) 112 | 113 | try: 114 | logger.debug('Attempting to delete record: %s', data) 115 | response = self.http.send('https://api.reg.ru/api/regru2/zone/remove_record', data) 116 | except requests.exceptions.RequestException as e: 117 | logger.warning('Encountered error deleting TXT record: %s', e) 118 | return 119 | 120 | if 'result' not in response or response['result'] != 'success': 121 | logger.warning('Encountered error deleting TXT record: %s', response) 122 | return 123 | 124 | logger.debug('Successfully deleted TXT record.') 125 | 126 | def _create_params(self, domain, input_data): 127 | """ 128 | Creates POST parameters. 129 | :param str domain: Domain name 130 | :param dict input_data: Input data 131 | :returns: POST parameters 132 | :rtype: dict 133 | """ 134 | pieces = domain.split('.') 135 | 136 | input_data['subdomain'] = '.'.join(pieces[:-2]) 137 | input_data['domains'] = [{'dname': '.'.join(pieces[-2:])}] 138 | 139 | data = self.options.copy() 140 | data.update({'input_data': json.dumps(input_data)}) 141 | 142 | return data 143 | 144 | 145 | class _HttpClient(object): 146 | """ 147 | Encapsulates HTTP requests 148 | """ 149 | 150 | def send(self, url, data): 151 | """ 152 | Sends a POST request. 153 | :param str url: URL for the new :class:`Request` object. 154 | :param dict data: Dictionary (will be form-encoded) to send in the body of the :class:`Request`. 155 | :raises requests.exceptions.RequestException: if an error occurs communicating with HTTP server 156 | """ 157 | 158 | response = requests.post(url, data=data) 159 | response.raise_for_status() 160 | 161 | return response.json() 162 | -------------------------------------------------------------------------------- /certbot_regru/dns_test.py: -------------------------------------------------------------------------------- 1 | """Tests for certbot_regru.dns.""" 2 | 3 | import os 4 | import unittest 5 | 6 | import json 7 | import mock 8 | import requests 9 | 10 | from certbot import errors 11 | from certbot.plugins import dns_test_common 12 | from certbot.plugins.dns_test_common import DOMAIN 13 | from certbot.tests import util as test_util 14 | 15 | USERNAME = 'foo' 16 | PASSWORD = 'bar' 17 | 18 | HTTP_ERROR = requests.exceptions.RequestException() 19 | 20 | 21 | class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest): 22 | 23 | def setUp(self): 24 | from certbot_regru.dns import Authenticator 25 | 26 | super(AuthenticatorTest, self).setUp() 27 | 28 | path = os.path.join(self.tempdir, 'file.ini') 29 | dns_test_common.write({"regru_username": USERNAME, "regru_password": PASSWORD}, path) 30 | 31 | self.config = mock.MagicMock(regru_credentials=path, regru_propagation_seconds=0) # don't wait during tests 32 | 33 | self.auth = Authenticator(self.config, "regru") 34 | 35 | self.mock_client = mock.MagicMock() 36 | # _get_regru_client | pylint: disable=protected-access 37 | self.auth._get_regru_client = mock.MagicMock(return_value=self.mock_client) 38 | 39 | def test_perform(self): 40 | self.auth.perform([self.achall]) 41 | 42 | expected = [mock.call.add_txt_record('_acme-challenge.' + DOMAIN, mock.ANY)] 43 | self.assertEqual(expected, self.mock_client.mock_calls) 44 | 45 | def test_cleanup(self): 46 | # _attempt_cleanup | pylint: disable=protected-access 47 | self.auth._attempt_cleanup = True 48 | self.auth.cleanup([self.achall]) 49 | 50 | expected = [mock.call.del_txt_record('_acme-challenge.' + DOMAIN, mock.ANY)] 51 | self.assertEqual(expected, self.mock_client.mock_calls) 52 | 53 | 54 | class RegRuClientTest(unittest.TestCase): 55 | 56 | record_prefix = "_acme-challenge" 57 | record_name = record_prefix + "." + DOMAIN 58 | record_content = "test" 59 | 60 | def setUp(self): 61 | from certbot_regru.dns import _RegRuClient 62 | 63 | self.client = _RegRuClient(USERNAME, PASSWORD) 64 | 65 | self.http = mock.MagicMock() 66 | self.client.http = self.http 67 | 68 | def test_add_txt_record(self): 69 | self.http.send.return_value = {'result': 'success'} 70 | self.client.add_txt_record(self.record_name, self.record_content) 71 | 72 | self.http.send.assert_called_with('https://api.reg.ru/api/regru2/zone/add_txt', { 73 | 'username': USERNAME, 74 | 'password': PASSWORD, 75 | 'io_encoding': 'utf8', 76 | 'show_input_params': 1, 77 | 'output_format': 'json', 78 | 'input_format': 'json', 79 | 'input_data': json.dumps({ 80 | 'text': self.record_content, 81 | 'subdomain': self.record_prefix, 82 | 'domains': [{'dname': DOMAIN}] 83 | }) 84 | }) 85 | 86 | def test_add_txt_record_subdomain(self): 87 | self.http.send.return_value = {'result': 'success'} 88 | self.client.add_txt_record(self.record_prefix + '.subdomain.' + DOMAIN, self.record_content) 89 | 90 | self.http.send.assert_called_with('https://api.reg.ru/api/regru2/zone/add_txt', { 91 | 'username': USERNAME, 92 | 'password': PASSWORD, 93 | 'io_encoding': 'utf8', 94 | 'show_input_params': 1, 95 | 'output_format': 'json', 96 | 'input_format': 'json', 97 | 'input_data': json.dumps({ 98 | 'text': self.record_content, 99 | 'subdomain': self.record_prefix + '.subdomain', 100 | 'domains': [{'dname': DOMAIN}] 101 | }) 102 | }) 103 | 104 | def test_add_txt_record_error_failed_result(self): 105 | self.http.send.return_value = {'result': 'failed'} 106 | self.assertRaises(errors.PluginError, self.client.add_txt_record, self.record_name, self.record_content) 107 | 108 | def test_add_txt_record_error_no_result(self): 109 | self.http.send.return_value = {} 110 | self.assertRaises(errors.PluginError, self.client.add_txt_record, self.record_name, self.record_content) 111 | 112 | def test_add_txt_record_error_send_request(self): 113 | self.http.send.side_effect = HTTP_ERROR 114 | self.assertRaises(errors.PluginError, self.client.add_txt_record, self.record_name, self.record_content) 115 | 116 | def test_del_txt_record(self): 117 | self.http.send.return_value = {'result': 'success'} 118 | self.client.del_txt_record(self.record_name, self.record_content) 119 | 120 | self.http.send.assert_called_with('https://api.reg.ru/api/regru2/zone/remove_record', { 121 | 'username': USERNAME, 122 | 'password': PASSWORD, 123 | 'io_encoding': 'utf8', 124 | 'show_input_params': 1, 125 | 'output_format': 'json', 126 | 'input_format': 'json', 127 | 'input_data': json.dumps({ 128 | 'record_type': 'TXT', 129 | 'content': self.record_content, 130 | 'subdomain': self.record_prefix, 131 | 'domains': [{'dname': DOMAIN}] 132 | }) 133 | }) 134 | 135 | def test_del_txt_record_subdomain(self): 136 | self.http.send.return_value = {'result': 'success'} 137 | self.client.del_txt_record(self.record_prefix + '.subdomain.' + DOMAIN, self.record_content) 138 | 139 | self.http.send.assert_called_with('https://api.reg.ru/api/regru2/zone/remove_record', { 140 | 'username': USERNAME, 141 | 'password': PASSWORD, 142 | 'io_encoding': 'utf8', 143 | 'show_input_params': 1, 144 | 'output_format': 'json', 145 | 'input_format': 'json', 146 | 'input_data': json.dumps({ 147 | 'record_type': 'TXT', 148 | 'content': self.record_content, 149 | 'subdomain': self.record_prefix + '.subdomain', 150 | 'domains': [{'dname': DOMAIN}] 151 | }) 152 | }) 153 | 154 | def test_del_txt_record_error_failed_result(self): 155 | self.http.send.return_value = {'result': 'failed'} 156 | self.client.del_txt_record(self.record_name, self.record_content) 157 | 158 | def test_del_txt_record_error_no_result(self): 159 | self.http.send.return_value = {} 160 | self.client.del_txt_record(self.record_name, self.record_content) 161 | 162 | def test_del_txt_record_error_send_request(self): 163 | self.http.send.side_effect = HTTP_ERROR 164 | self.client.del_txt_record(self.record_name, self.record_content) 165 | 166 | if __name__ == "__main__": 167 | unittest.main() # pragma: no cover 168 | -------------------------------------------------------------------------------- /regru.ini: -------------------------------------------------------------------------------- 1 | certbot_regru:dns_username=test 2 | certbot_regru:dns_password=test 3 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from setuptools import find_packages 3 | 4 | from certbot_regru import __version__ 5 | 6 | install_requires = [ 7 | 'acme>=0.21.1', 8 | 'certbot>=0.21.1', 9 | 'requests>=2.9.1', 10 | 'mock', 11 | 'setuptools', 12 | 'zope.interface', 13 | ] 14 | 15 | data_files = [ 16 | ('/etc/letsencrypt', ['regru.ini']) 17 | ] 18 | 19 | with open('README.md') as f: 20 | long_description = f.read() 21 | 22 | setup( 23 | name='certbot-regru', 24 | version=__version__, 25 | description="Reg.ru DNS authenticator plugin for Certbot", 26 | long_description=long_description, 27 | long_description_content_type='text/markdown', 28 | url='https://github.com/free2er/certbot-regru', 29 | author="Max Pryakhin", 30 | author_email='m.pryakhin@gmail.com', 31 | license='MIT', 32 | python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', 33 | classifiers=[ 34 | 'Development Status :: 3 - Alpha', 35 | 'Environment :: Plugins', 36 | 'Intended Audience :: System Administrators', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Operating System :: POSIX :: Linux', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 2', 41 | 'Programming Language :: Python :: 2.7', 42 | 'Programming Language :: Python :: 3', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Programming Language :: Python :: 3.5', 45 | 'Programming Language :: Python :: 3.6', 46 | 'Topic :: Internet :: WWW/HTTP', 47 | 'Topic :: Security', 48 | 'Topic :: System :: Installation/Setup', 49 | 'Topic :: System :: Networking', 50 | 'Topic :: System :: Systems Administration', 51 | 'Topic :: Utilities', 52 | ], 53 | install_requires=install_requires, 54 | data_files=data_files, 55 | packages=find_packages(), 56 | include_package_data=True, 57 | entry_points={ 58 | 'certbot.plugins': [ 59 | 'dns = certbot_regru.dns:Authenticator', 60 | ], 61 | }, 62 | test_suite='certbot_regru', 63 | ) 64 | --------------------------------------------------------------------------------