├── .envrc ├── .github └── workflows │ └── release.yml ├── .gitignore ├── Dockerfile ├── LICENCE ├── Makefile ├── README.md ├── certbot_plugin_websupport ├── __init__.py ├── dns.py └── dns_test.py ├── pytest.ini ├── requirements-dev.txt ├── setup.cfg └── setup.py /.envrc: -------------------------------------------------------------------------------- 1 | export VIRTUAL_ENV=.venv 2 | layout python3 3 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - "*" 7 | tags: 8 | - "*" 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 3.12 17 | uses: actions/setup-python@v4 18 | with: 19 | python-version: 3.12 20 | - name: Install setuptools and 21 | run: | 22 | python -m pip install --upgrade setuptools wheel 23 | python -m pip install -r requirements-dev.txt 24 | - name: Test 25 | run: pytest 26 | 27 | publish: 28 | name: Publish 29 | if: github.event_name == 'push' && contains(github.ref, 'refs/tags/') 30 | needs: test 31 | runs-on: ubuntu-latest 32 | permissions: 33 | id-token: write 34 | steps: 35 | - uses: actions/checkout@v3 36 | - name: Set up Python 3.12 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: 3.12 40 | - name: Install setuptools and 41 | run: python -m pip install --upgrade setuptools wheel 42 | - name: Build a binary wheel and a source tarball 43 | run: python setup.py build sdist bdist_wheel 44 | - name: Publish distribution 📦 to PyPI 45 | uses: pypa/gh-action-pypi-publish@release/v1 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | *.pyc 3 | *.egg-info 4 | *.pyo 5 | build 6 | dist 7 | .venv 8 | .pytest_cache 9 | __pycache__ 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM certbot/certbot 2 | RUN pip3 install certbot-plugin-websupport 3 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019, 2020, 2024 Martin Jantošovič 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default 2 | SHELL := /bin/bash 3 | 4 | default: build 5 | 6 | cleanup: 7 | rm -rf dist build certbot_plugin_websupport.egg-info 8 | find . -name "*.pyc" -delete 9 | find . -name "*.egg-info" -delete 10 | find . -name "*.pyo" -delete 11 | 12 | build: cleanup 13 | python setup.py bdist_wheel 14 | 15 | upload: 16 | twine upload dist/* 17 | 18 | test: 19 | python -m pytest 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Certbot plugin for authentication using Websupport REST API 2 | 3 | This is a plugin for [Certbot](https://certbot.eff.org/) that uses the Websupport REST API to allow [Websupport](https://wwww.websupport.sk/) 4 | customers to prove control of a domain name. 5 | 6 | ## Usage 7 | 8 | 1. Obtain an API key and API secret (see [Account Page](https://admin.websupport.sk/sk/auth/security-settings)) 9 | 10 | 2. Install the plugin using `pip install certbot-plugin-websupport` 11 | 12 | 3. Create a `websupport.ini` config file with the following contents and apply `chmod 600 websupport.ini` on it: 13 | ``` 14 | dns_websupport_api_key = APIKEY 15 | dns_websupport_api_secret = SECRET 16 | ``` 17 | Replace `APIKEY` with your Websupport API key, `SECRET` with your API secret and ensure permissions are set 18 | to disallow access to other users. 19 | 20 | 4. Run `certbot` and direct it to use the plugin for authentication and to use 21 | the config file previously created: 22 | ``` 23 | certbot certonly -a dns-websupport --dns-websupport-credentials websupport.ini -d domain.com 24 | ``` 25 | Add additional options as required to specify an installation plugin etc. 26 | 27 | Please note that this solution is usually not relevant if you're using Websupport's web hosting services as Websupport offers free automated certificates for all simplehosting plans having SSL in the admin interface. 28 | 29 | ## Updates 30 | 31 | This plugin can be updated by running: 32 | 33 | ``` 34 | pip install certbot-plugin-websupport --upgrade 35 | ``` 36 | 37 | ## Wildcard certificates 38 | 39 | This plugin is particularly useful when you need to obtain a wildcard certificate using dns challenges: 40 | 41 | ``` 42 | certbot certonly -a dns-websupport --dns-websupport-credentials websupport.ini -d domain.com -d \*.domain.com 43 | ``` 44 | 45 | ## Automatic renewal 46 | 47 | You can setup automatic renewal using `crontab` with the following job for weekly renewal attempts: 48 | 49 | ``` 50 | 0 0 * * 0 certbot renew -q -a dns-websupport --dns-websupport-credentials /etc/letsencrypt/websupport.ini 51 | ``` 52 | 53 | ## Docker 54 | 55 | You can use `Dockerfile` to build a image: 56 | 57 | ```Dockerfile 58 | FROM certbot/certbot 59 | RUN pip3 install certbot-plugin-websupport 60 | ``` 61 | 62 | E.g: 63 | 64 | ```bash 65 | docker build -t certbot/dns-websupport . 66 | ``` 67 | 68 | Then you can generate certificate using: 69 | 70 | ```bash 71 | docker run -it --rm \ 72 | -v /var/lib/letsencrypt:/var/lib/letsencrypt \ 73 | -v /etc/letsencrypt:/etc/letsencrypt \ 74 | certbot/dns-websupport \ 75 | certonly \ 76 | --authenticator dns-websupport \ 77 | --dns-websupport-credentials "/etc/letsencrypt/.secrets/credentials.ini" \ 78 | --email full.name@example.com \ 79 | --agree-tos \ 80 | --non-interactive \ 81 | --rsa-key-size 4096 \ 82 | -d *.example.com 83 | ``` 84 | 85 | And renewal: 86 | 87 | ```bash 88 | docker run -it --rm \ 89 | -v /var/lib/letsencrypt:/var/lib/letsencrypt \ 90 | -v /etc/letsencrypt:/etc/letsencrypt \ 91 | certbot/dns-websupport \ 92 | certonly renew \ 93 | --authenticator dns-websupport \ 94 | --dns-websupport-credentials "/etc/letsencrypt/.secrets/credentials.ini" 95 | ``` 96 | -------------------------------------------------------------------------------- /certbot_plugin_websupport/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Mordred/certbot-plugin-websupport/98001ed473617a47a20a89cd220fabd72128afa0/certbot_plugin_websupport/__init__.py -------------------------------------------------------------------------------- /certbot_plugin_websupport/dns.py: -------------------------------------------------------------------------------- 1 | """DNS Authenticator for Websupport.""" 2 | import logging 3 | 4 | import requests 5 | import zope.interface 6 | import hmac 7 | import hashlib 8 | import base64 9 | import time 10 | from datetime import datetime, timezone 11 | 12 | from certbot import errors 13 | from certbot import interfaces 14 | from certbot.plugins import dns_common 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | API_ENDPOINT = 'https://rest.websupport.sk' 19 | ACCOUNT_URL = 'https://admin.websupport.sk/sk/auth/apiKey' 20 | 21 | @zope.interface.implementer(interfaces.IAuthenticator) 22 | @zope.interface.provider(interfaces.IPluginFactory) 23 | class Authenticator(dns_common.DNSAuthenticator): 24 | """DNS Authenticator for Websupport 25 | 26 | This Authenticator uses the Websupport API to fulfill a dns-01 challenge. 27 | """ 28 | 29 | description = ('Obtain certificates using a DNS TXT record (if you are using Websupport for ' 30 | 'DNS).') 31 | ttl = 120 32 | 33 | def __init__(self, config, name, **kwargs): 34 | if name in ("dns", "certbot-plugin-websupport:dns"): 35 | logger.warning("""Certbot is moving to remove 3rd party plugins prefixes. 36 | 37 | Please use --authenticator dns-websupport --dns-websupport-credentials 38 | 39 | See: https://github.com/certbot/certbot/pull/8131 40 | """) 41 | super(Authenticator, self).__init__(config, name, **kwargs) 42 | self.credentials = None 43 | 44 | @classmethod 45 | def add_parser_arguments(cls, add): # pylint: disable=arguments-differ 46 | super(Authenticator, cls).add_parser_arguments(add) 47 | add('credentials', help='Websupport credentials INI file.') 48 | 49 | def more_info(self): # pylint: disable=missing-docstring,no-self-use 50 | return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ 51 | 'the Websupport API.' 52 | 53 | def _setup_credentials(self): 54 | self.credentials = self._configure_credentials( 55 | 'credentials', 56 | 'Websupport credentials INI file', 57 | { 58 | 'api-key': 'API key for Websupport account, obtained from {0}'.format(ACCOUNT_URL), 59 | 'api-secret': 'API secret for Websupport account, obtained from {0}'.format(ACCOUNT_URL) 60 | } 61 | ) 62 | 63 | def _perform(self, domain, validation_name, validation): 64 | self._get_websupport_client().add_txt_record(domain, validation_name, validation, self.ttl) 65 | 66 | def _cleanup(self, domain, validation_name, validation): 67 | self._get_websupport_client().del_txt_record(domain, validation_name, validation) 68 | 69 | def _get_websupport_client(self): 70 | return _WebsupportClient(self.credentials.conf('api-key'), self.credentials.conf('api-secret')) 71 | 72 | 73 | class _WebsupportClient(object): 74 | """ 75 | Encapsulates all communication with the Websupport REST API. 76 | """ 77 | 78 | def __init__(self, api_key, api_secret): 79 | self.api_key = api_key 80 | self.api_secret = api_secret 81 | 82 | 83 | def add_txt_record(self, domain, record_name, record_content, record_ttl): 84 | """ 85 | Add a TXT record using the supplied information. 86 | 87 | :param str domain: The domain to use to look up the Websupport zone. 88 | :param str record_name: The record name (typically beginning with '_acme-challenge.'). 89 | :param str record_content: The record content (typically the challenge validation). 90 | :param int record_ttl: The record TTL (number of seconds that the record may be cached). 91 | :raises certbot.errors.PluginError: if an error occurs communicating with the Websupport API 92 | """ 93 | 94 | zone_id = self._find_zone_id(domain) 95 | name = record_name.replace(zone_id, '').strip('.') 96 | 97 | data = { 98 | 'type': 'TXT', 99 | 'name': name, 100 | 'content': record_content, 101 | 'ttl': record_ttl 102 | } 103 | 104 | logger.debug('Attempting to add record to zone %s: %s', zone_id, data) 105 | response = self._send_request('POST', '/v1/user/self/zone/{0}/record'.format(zone_id), data) 106 | 107 | if response.status_code != 200 and response.status_code != 201: 108 | raise errors.PluginError('Error communicating with Websupport API: {0}'.format(response.status_code)) 109 | 110 | response_json = response.json() 111 | if response_json['status'] == 'error': 112 | raise errors.PluginError('Error communicating with Websupport API: {0} - {1}'.format(response['errors']['name'][0], response['errors']['content'][0])) 113 | 114 | record_id = response_json['item']['id'] 115 | logger.debug('Successfully added TXT record with record_id: %s', record_id) 116 | 117 | 118 | def del_txt_record(self, domain, record_name, record_content): 119 | """ 120 | Delete a TXT record using the supplied information. 121 | 122 | Note that both the record's name and content are used to ensure that similar records 123 | created concurrently (e.g., due to concurrent invocations of this plugin) are not deleted. 124 | 125 | Failures are logged, but not raised. 126 | 127 | :param str domain: The domain to use to look up the Websupport zone. 128 | :param str record_name: The record name (typically beginning with '_acme-challenge.'). 129 | :param str record_content: The record content (typically the challenge validation). 130 | """ 131 | 132 | try: 133 | zone_id = self._find_zone_id(domain) 134 | except errors.PluginError as e: 135 | logger.debug('Encountered error finding zone_id during deletion: %s', e) 136 | return 137 | 138 | name = record_name.replace(zone_id, '').strip('.') 139 | if zone_id: 140 | record_id = self._find_txt_record_id(zone_id, name, record_content) 141 | if record_id: 142 | response = self._send_request('DELETE', '/v1/user/self/zone/{0}/record/{1}'.format(zone_id, record_id)) 143 | if response.status_code == 200: 144 | logger.debug('Successfully deleted TXT record.') 145 | else: 146 | logger.warning('Encountered Websupport error when deleting record: {0}'.format(record_id)) 147 | else: 148 | logger.debug('TXT record not found; no cleanup needed.') 149 | else: 150 | logger.debug('Zone not found; no cleanup needed.') 151 | 152 | 153 | def _find_zone_id(self, domain): 154 | """ 155 | Find the zone_id for a given domain. 156 | 157 | :param str domain: The domain for which to find the zone_id. 158 | :returns: The zone_id, if found. 159 | :rtype: str 160 | :raises certbot.errors.PluginError: if no zone_id is found. 161 | """ 162 | 163 | parts = domain.split('.') 164 | zone_id = '.'.join(parts[-2:]) 165 | 166 | response = self._send_request('GET', '/v1/user/self/zone/{0}'.format(zone_id)) 167 | 168 | if response.status_code == 200: 169 | return zone_id 170 | elif response.status_code == 401 or response.status_code == 403: 171 | raise errors.PluginError('Error determining zone_id: {0}. Please confirm that ' 172 | 'you have supplied valid Websupport API credentials.' 173 | .format(response.status_code)) 174 | else: 175 | raise errors.PluginError('Unable to determine zone_id for {0}. ' 176 | 'Please confirm that the domain name has been entered correctly ' 177 | 'and is already associated with the supplied Websupport account.' 178 | .format(domain)) 179 | 180 | def _find_txt_record_id(self, zone_id, record_name, record_content): 181 | """ 182 | Find the record_id for a TXT record with the given name and content. 183 | 184 | :param str zone_id: The zone_id which contains the record. 185 | :param str record_name: The record name (typically beginning with '_acme-challenge.'). 186 | :param str record_content: The record content (typically the challenge validation). 187 | :returns: The record_id, if found. 188 | :rtype: str 189 | """ 190 | 191 | response = self._send_request('GET', '/v1/user/self/zone/{0}/record'.format(zone_id)) 192 | 193 | if response.status_code == 404: 194 | logger.debug('Unable to find TXT record.') 195 | return None 196 | 197 | response_json = response.json() 198 | for item in response_json['items']: 199 | if item['type'] == 'TXT' and item['name'] == record_name and item['content'] == record_content: 200 | return item['id'] 201 | else: 202 | logger.debug('Unable to find TXT record.') 203 | return None 204 | 205 | 206 | def _send_request(self, method, path, data=None): 207 | timestamp = int(time.time()) 208 | canonical_request = "%s %s %s" % (method, path, timestamp) 209 | signature = hmac.new(self.api_secret.encode('utf-8'), canonical_request.encode('utf-8'), hashlib.sha1).hexdigest() 210 | 211 | headers = { 212 | "Authorization": "Basic {0}".format(base64.b64encode("{0}:{1}".format(self.api_key, signature).encode('utf-8')).decode('utf-8')), 213 | "Content-Type": "application/json", 214 | "Accept": "application/json", 215 | "Date": datetime.fromtimestamp(timestamp, timezone.utc).isoformat() 216 | } 217 | 218 | return requests.request(method, '%s%s' % (API_ENDPOINT, path), headers=headers, json=data) 219 | 220 | -------------------------------------------------------------------------------- /certbot_plugin_websupport/dns_test.py: -------------------------------------------------------------------------------- 1 | import sys 2 | sys.path.insert(0, '../') 3 | 4 | import unittest 5 | 6 | import mock 7 | 8 | from certbot import errors 9 | from certbot.compat import os 10 | from certbot.plugins import dns_test_common 11 | from certbot.plugins.dns_test_common import DOMAIN 12 | from certbot.tests import util as test_util 13 | 14 | FAKE_API_KEY = "api_key" 15 | FAKE_API_SECRET = "secret" 16 | 17 | class AuthenticatorTest( 18 | test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest 19 | ): 20 | def setUp(self): 21 | super(AuthenticatorTest, self).setUp() 22 | 23 | mock.patch("certbot.display.util.notify", lambda x: ...).start() 24 | 25 | from certbot_plugin_websupport.dns import Authenticator 26 | 27 | path = os.path.join(self.tempdir, "websupport.ini") 28 | dns_test_common.write( 29 | { 30 | "dns_websupport_api_key": FAKE_API_KEY, 31 | "dns_websupport_api_secret": FAKE_API_SECRET, 32 | }, 33 | path, 34 | ) 35 | 36 | super(AuthenticatorTest, self).setUp() 37 | self.config = mock.MagicMock( 38 | dns_websupport_credentials=path, dns_websupport_propagation_seconds=0 39 | ) # don't wait during tests 40 | 41 | self.auth = Authenticator(self.config, "dns-websupport") 42 | 43 | self.mock_client = mock.MagicMock() 44 | # _get_ispconfig_client | pylint: disable=protected-access 45 | self.auth._get_websupport_client = mock.MagicMock(return_value=self.mock_client) 46 | 47 | def test_perform(self): 48 | self.auth.perform([self.achall]) 49 | 50 | expected = [ 51 | mock.call.add_txt_record( 52 | DOMAIN, "_acme-challenge." + DOMAIN, mock.ANY, mock.ANY 53 | ) 54 | ] 55 | self.assertEqual(expected, self.mock_client.mock_calls) 56 | 57 | def test_cleanup(self): 58 | # _attempt_cleanup | pylint: disable=protected-access 59 | self.auth._attempt_cleanup = True 60 | self.auth.cleanup([self.achall]) 61 | 62 | expected = [ 63 | mock.call.del_txt_record( 64 | DOMAIN, "_acme-challenge." + DOMAIN, mock.ANY 65 | ) 66 | ] 67 | self.assertEqual(expected, self.mock_client.mock_calls) 68 | 69 | 70 | # class WebsupportClient(unittest.TestCase): 71 | 72 | # def test_wrong_credentials_for_find_zone_id(self): 73 | # client = _WebsupportClient('api_key', 'secret') 74 | # with self.assertRaises(errors.PluginError): 75 | # client._find_zone_id('mydomain.cz') 76 | 77 | 78 | if __name__ == '__main__': 79 | unittest.main() 80 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | filterwarnings = 3 | ignore:.*in pyOpenSSL is deprecated:DeprecationWarning 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | mock==5.1.0 2 | certbot==3.0.0 3 | zope.interface==7.1.1 4 | pytest==8.3.3 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from setuptools import find_packages 3 | 4 | 5 | with open("README.md", "r") as fh: 6 | long_description = fh.read() 7 | 8 | version = '0.2.0' 9 | 10 | # Remember to update local-oldest-requirements.txt when changing the minimum 11 | # acme/certbot version. 12 | install_requires = [ 13 | 'certbot>=0.38.0', 14 | 'requests', 15 | 'setuptools', 16 | 'zope.interface', 17 | ] 18 | 19 | docs_extras = [ 20 | 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags 21 | 'sphinx_rtd_theme', 22 | ] 23 | 24 | setup( 25 | name='certbot-plugin-websupport', 26 | version=version, 27 | description="Websupport DNS Authenticator plugin for Certbot", 28 | long_description=long_description, 29 | long_description_content_type="text/markdown", 30 | url='https://github.com/Mordred/certbot-plugin-websupport', 31 | author="Martin Jantosovic", 32 | author_email='jantosovic.martin@gmail.com', 33 | license='MIT', 34 | python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*', 35 | classifiers=[ 36 | 'Development Status :: 3 - Alpha', 37 | 'Environment :: Plugins', 38 | 'Intended Audience :: System Administrators', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Operating System :: POSIX :: Linux', 41 | 'Programming Language :: Python', 42 | 'Programming Language :: Python :: 2', 43 | 'Programming Language :: Python :: 2.7', 44 | 'Programming Language :: Python :: 3', 45 | 'Programming Language :: Python :: 3.4', 46 | 'Programming Language :: Python :: 3.5', 47 | 'Programming Language :: Python :: 3.6', 48 | 'Programming Language :: Python :: 3.7', 49 | "Programming Language :: Python :: 3.8", 50 | "Programming Language :: Python :: 3.9", 51 | "Programming Language :: Python :: 3.10", 52 | "Programming Language :: Python :: 3.11", 53 | "Programming Language :: Python :: 3.12", 54 | 'Topic :: Internet :: WWW/HTTP', 55 | 'Topic :: Security', 56 | 'Topic :: System :: Installation/Setup', 57 | 'Topic :: System :: Networking', 58 | 'Topic :: System :: Systems Administration', 59 | 'Topic :: Utilities', 60 | ], 61 | 62 | packages=find_packages(), 63 | include_package_data=True, 64 | install_requires=install_requires, 65 | extras_require={ 66 | 'docs': docs_extras, 67 | }, 68 | entry_points={ 69 | 'certbot.plugins': [ 70 | 'dns = certbot_plugin_websupport.dns:Authenticator', 71 | 'dns-websupport = certbot_plugin_websupport.dns:Authenticator', 72 | ], 73 | }, 74 | ) 75 | --------------------------------------------------------------------------------