├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── certbot_dns_cpanel ├── __init__.py └── dns_cpanel.py ├── credentials.ini.exemple ├── setup.cfg └── setup.py /.dockerignore: -------------------------------------------------------------------------------- 1 | env/ 2 | venv/ 3 | .git 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # ---> Python 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | env/ 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # ---> VirtualEnv 98 | # Virtualenv 99 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 100 | .Python 101 | [Bb]in 102 | [Ii]nclude 103 | [Ll]ib 104 | [Ll]ib64 105 | [Ll]ocal 106 | [Ss]cripts 107 | pyvenv.cfg 108 | .venv 109 | pip-selfcheck.json 110 | 111 | # ---> Vim 112 | # swap 113 | [._]*.s[a-v][a-z] 114 | [._]*.sw[a-p] 115 | [._]s[a-v][a-z] 116 | [._]sw[a-p] 117 | # session 118 | Session.vim 119 | # temporary 120 | .netrwhist 121 | *~ 122 | # auto-generated tag files 123 | tags 124 | 125 | # ---> VisualStudioCode 126 | .vscode/* 127 | !.vscode/settings.json 128 | !.vscode/tasks.json 129 | !.vscode/launch.json 130 | !.vscode/extensions.json 131 | 132 | # Configuration files 133 | credentials.ini 134 | 135 | # ---> Intellij IDEs 136 | /.idea 137 | 138 | # Local certbot files and folders 139 | # These are created when running certbot with 140 | # `certbot --work-dir . --config-dir . --logs-dir . ...` 141 | accounts/ 142 | archive/ 143 | backups/ 144 | csr/ 145 | keys/ 146 | live/ 147 | renewal/ 148 | renewal-hooks/ 149 | letsencrypt.log* 150 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM certbot/certbot:v1.3.0 2 | COPY . /certbot-dns-cpanel 3 | RUN pip install -e /certbot-dns-cpanel 4 | 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Massaki Archambault 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | 15 | Apache License 16 | Version 2.0, January 2004 17 | http://www.apache.org/licenses/ 18 | 19 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 20 | 21 | 1. Definitions. 22 | 23 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 24 | 25 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 26 | 27 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 28 | 29 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 30 | 31 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 32 | 33 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 36 | 37 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 38 | 39 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 40 | 41 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 42 | 43 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 44 | 45 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 46 | 47 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 48 | 49 | (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and 50 | 51 | (b) You must cause any modified files to carry prominent notices stating that You changed the files; and 52 | 53 | (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 54 | 55 | (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 56 | 57 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 58 | 59 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 60 | 61 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 62 | 63 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 64 | 65 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 66 | 67 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 68 | 69 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # certbot-dns-cpanel 2 | 3 | Plugin to allow acme dns-01 authentication of a name managed in cPanel. Useful for automating and creating a Let's Encrypt certificate (wildcard or not) for a service with a name managed by cPanel, but installed on a server not managed in cPanel. 4 | 5 | ## Named Arguments 6 | | Argument | Description | 7 | | --- | --- | 8 | | --certbot-dns-cpanel:cpanel-credentials <file> | cPanel credentials INI file **(required)** | 9 | | --certbot-dns-cpanel:cpanel-propagation-seconds <seconds> | The number of seconds to wait for DNS to propagate before asking the ACME server to verify the DNS record (Default: 30) | 10 | 11 | ## Install 12 | ``` bash 13 | pip install certbot-dns-cpanel 14 | ``` 15 | 16 | ## Credentials 17 | Download the file `credentials.ini.example` and rename it to `credentials.ini`. Edit it to set your cPanel url, username and password. 18 | ``` 19 | # The url cPanel url 20 | # include the scheme and the port number (usually 2083 for https) 21 | certbot_dns_cpanel:cpanel_url = https://cpanel.example.com:2083 22 | 23 | # The cPanel username 24 | certbot_dns_cpanel:cpanel_username = user 25 | 26 | # The cPanel password 27 | certbot_dns_cpanel:cpanel_password = hunter2 28 | ``` 29 | 30 | ## Example 31 | You can now run certbot using the plugin and feeding the credentials file. 32 | For example, to get a wildcard certificate for *.example.com and example.com: 33 | ``` bash 34 | certbot certonly \ 35 | --authenticator certbot-dns-cpanel:cpanel \ 36 | --certbot-dns-cpanel:cpanel-credentials /path/to/credentials.ini \ 37 | -d 'example.com' \ 38 | -d '*.example.com' 39 | ``` 40 | 41 | You can also specify a installer plugin with the `--installer` option: 42 | 43 | ``` bash 44 | certbot run \ 45 | --authenticator certbot-dns-cpanel:cpanel \ 46 | --installer apache \ 47 | --certbot-dns-cpanel:cpanel-credentials /path/to/credentials.ini \ 48 | -d 'example.com' \ 49 | -d '*.example.com' 50 | ``` 51 | 52 | You may also install the certificate onto a domain on your cPanel account: 53 | 54 | ```bash 55 | certbot run \ 56 | --authenticator certbot-dns-cpanel:cpanel \ 57 | --installer certbot-dns-cpanel:cpanel \ 58 | --certbot-dns-cpanel:cpanel-credentials /path/to/credentials.ini \ 59 | -d 'example.com' \ 60 | -d '*.example.com' 61 | ``` 62 | 63 | Depending on your provider you may need to use the `--certbot-dns-cpanel:cpanel-propagation-seconds` option to extend 64 | the DNS propagation time. 65 | 66 | ## Docker 67 | A docker image [badjware/certbot-dns-cpanel](https://hub.docker.com/r/badjware/certbot-dns-cpanel), based on [certbot/certbot](https://hub.docker.com/r/certbot/certbot) is provided for your convenience: 68 | ``` bash 69 | docker run -it \ 70 | -v /path/to/credentials.ini:/tmp/credentials.ini \ 71 | badjware/certbot-dns-cpanel \ 72 | certonly \ 73 | --authenticator certbot-dns-cpanel:cpanel \ 74 | --certbot-dns-cpanel:cpanel-credentials /tmp/credentials.ini \ 75 | -d 'example.com' \ 76 | -d '*.example.com' 77 | ``` 78 | 79 | ## Additional documentation 80 | * https://documentation.cpanel.net/display/DD/Guide+to+cPanel+API+2 81 | * https://certbot.eff.org/docs/ 82 | -------------------------------------------------------------------------------- /certbot_dns_cpanel/__init__.py: -------------------------------------------------------------------------------- 1 | """cPanel dns-01 authenticator plugin""" 2 | -------------------------------------------------------------------------------- /certbot_dns_cpanel/dns_cpanel.py: -------------------------------------------------------------------------------- 1 | """cPanel dns-01 authenticator & installer plugin""" 2 | import logging 3 | import base64 4 | import json 5 | import re 6 | 7 | try: 8 | # python 3 9 | from urllib.request import urlopen, Request 10 | from urllib.parse import urlencode 11 | except ImportError: 12 | # python 2 13 | from urllib import urlencode 14 | from urllib2 import urlopen, Request # type: ignore 15 | 16 | import zope.interface 17 | 18 | from certbot import errors 19 | from certbot import interfaces 20 | from certbot.plugins import common 21 | from certbot.plugins import dns_common 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | @zope.interface.implementer(interfaces.IAuthenticator) 27 | @zope.interface.implementer(interfaces.IInstaller) 28 | @zope.interface.provider(interfaces.IPluginFactory) 29 | class CpanelConfigurator(dns_common.DNSAuthenticator, common.Installer): 30 | """cPanel dns-01 authenticator & installer plugin""" 31 | 32 | description = "Obtain a certificate using a DNS TXT record in cPanel and optionally install it" 33 | problem = "a" 34 | 35 | def __init__(self, *args, **kwargs): 36 | super(CpanelConfigurator, self).__init__(*args, **kwargs) 37 | self.credentials = None 38 | 39 | @classmethod 40 | def add_parser_arguments(cls, add): # pylint: disable=arguments-differ 41 | super(CpanelConfigurator, cls).add_parser_arguments(add, default_propagation_seconds=30) 42 | add("credentials", 43 | type=str, 44 | help="The cPanel credentials INI file") 45 | 46 | def more_info(self): # pylint: disable=missing-docstring 47 | return self.description 48 | 49 | def _validate_credentials(self, credentials): 50 | url = credentials.conf('url') 51 | username = credentials.conf('username') 52 | token = credentials.conf('token') 53 | password = credentials.conf('password') 54 | 55 | if not url: 56 | raise errors.PluginError('%s: url is required' % credentials.confobj.filename) 57 | 58 | if not username: 59 | raise errors.PluginError('%s: username and token (preferred) or password are required' % credentials.confobj.filename) 60 | 61 | if token: 62 | if password: 63 | logger.warning('%s: token and password are exclusive, token will be used when both are provided' % credentials.confobj.filename) 64 | elif not password: 65 | raise errors.PluginError('%s: password or token (preferred) are required' % credentials.confobj.filename) 66 | 67 | def _setup_credentials(self): 68 | self.credentials = self._configure_credentials( 69 | 'credentials', 70 | 'The cPanel credentials INI file', 71 | None, 72 | self._validate_credentials 73 | ) 74 | 75 | def _perform(self, domain, validation_domain_name, validation): 76 | self._get_cpanel_client().add_txt_record(validation_domain_name, validation) 77 | 78 | def _cleanup(self, domain, validation_domain_name, validation): 79 | self._get_cpanel_client().del_txt_record(validation_domain_name, validation) 80 | 81 | # installer methods 82 | def supported_enhancements(self): 83 | return [] 84 | 85 | def enhance(self, domain, enhancement, options=None): 86 | pass 87 | 88 | def config_test(self): 89 | pass 90 | 91 | def get_all_names(self): 92 | return [] 93 | 94 | def restart(self): 95 | pass 96 | 97 | def save(self, title=None, temporary=False): 98 | pass 99 | 100 | def deploy_cert(self, domain, cert_path, key_path, chain_path, fullchain_path): 101 | # ensure that we setup credentials if we are 102 | # called in installation mode only 103 | self._setup_credentials() 104 | 105 | if re.search(r'^\*\.', domain): 106 | domain = re.sub(r'^\*\.', '', domain) 107 | logger.debug("removed wildcard prefix from domain: " + domain) 108 | 109 | self._get_cpanel_client().install_ssl(domain, cert_path, key_path, chain_path) 110 | 111 | return 112 | 113 | def renew_deploy(self, lineage, *args, **kwargs): 114 | """ 115 | Renew certificates when calling `certbot renew` 116 | """ 117 | # Run deploy_cert with the lineage params 118 | self.deploy_cert(lineage.names()[0], lineage.cert_path, lineage.key_path, lineage.chain_path, lineage.fullchain_path) 119 | 120 | return 121 | 122 | def _get_cpanel_client(self): 123 | if not self.credentials: 124 | raise errors.Error('No auth data') 125 | 126 | if self.credentials.conf('token'): 127 | return _CPanelClient(self.credentials.conf('url'), self.credentials.conf('username'), None, self.credentials.conf('token')) 128 | elif self.credentials.conf('password'): 129 | return _CPanelClient(self.credentials.conf('url'), self.credentials.conf('username'), self.credentials.conf('password'), None) 130 | 131 | return _CPanelClient( 132 | self.credentials.conf('url'), 133 | self.credentials.conf('username'), 134 | self.credentials.conf('password'), 135 | self.credentials.conf('token'), 136 | ) 137 | 138 | 139 | class _CPanelClient: 140 | """Encapsulate communications with the cPanel API 2""" 141 | def __init__(self, url, username, password, token): 142 | self.request_url = "%s/json-api/cpanel" % url 143 | self.data = { 144 | 'cpanel_jsonapi_user': username, 145 | 'cpanel_jsonapi_apiversion': '2', 146 | } 147 | 148 | if token: 149 | self.headers = { 150 | 'Authorization': 'cpanel %s:%s' % (username, token) 151 | } 152 | else: 153 | self.headers = { 154 | 'Authorization': 'Basic %s' % base64.b64encode( 155 | ("%s:%s" % (username, password)).encode()).decode('utf8') 156 | } 157 | 158 | def add_txt_record(self, record_name, record_content, record_ttl=60): 159 | """Add a TXT record 160 | :param str record_name: the domain name to add 161 | :param str record_content: the content of the TXT record to add 162 | :param int record_ttl: the TTL of the record to add 163 | """ 164 | cpanel_zone, cpanel_name = self._get_zone_and_name(record_name) 165 | 166 | data = self.data.copy() 167 | data['cpanel_jsonapi_module'] = 'ZoneEdit' 168 | data['cpanel_jsonapi_func'] = 'add_zone_record' 169 | data['domain'] = cpanel_zone 170 | data['name'] = cpanel_name 171 | data['type'] = 'TXT' 172 | data['txtdata'] = record_content 173 | data['ttl'] = record_ttl 174 | 175 | response = urlopen( 176 | Request( 177 | "%s?%s" % (self.request_url, urlencode(data)), 178 | headers=self.headers, 179 | ) 180 | ) 181 | response_data = json.load(response)['cpanelresult'] 182 | logger.debug("add_zone_record: url='%s', data='%s', response data='%s'" % ( 183 | self.request_url, json.dumps(data, indent=4), json.dumps(response_data, indent=4) ) ) 184 | if response_data['data'][0]['result']['status'] == 1: 185 | logger.info("Successfully added TXT record for %s", record_name) 186 | else: 187 | raise errors.PluginError("Error adding TXT record: %s" % response_data['data'][0]['result']['statusmsg']) 188 | 189 | def del_txt_record(self, record_name, record_content, record_ttl=60): 190 | """Remove a TXT record 191 | :param str record_name: the domain name to remove 192 | :param str record_content: the content of the TXT record to remove 193 | :param int record_ttl: the TTL of the record to remove 194 | """ 195 | cpanel_zone, _ = self._get_zone_and_name(record_name) 196 | 197 | record_lines = self._get_record_line(cpanel_zone, record_name, record_content, record_ttl) 198 | 199 | data = self.data.copy() 200 | data['cpanel_jsonapi_module'] = 'ZoneEdit' 201 | data['cpanel_jsonapi_func'] = 'remove_zone_record' 202 | data['domain'] = cpanel_zone 203 | 204 | # the lines get shifted when we remove one, so we reverse-sort to avoid that 205 | record_lines.sort(reverse=True) 206 | for record_line in record_lines: 207 | data['line'] = record_line 208 | 209 | response = urlopen( 210 | Request( 211 | "%s?%s" % (self.request_url, urlencode(data)), 212 | headers=self.headers 213 | ) 214 | ) 215 | response_data = json.load(response)['cpanelresult'] 216 | logger.debug("del_zone_record: url='%s', data='%s', response data='%s'" % ( 217 | self.request_url, json.dumps(data, indent=4), 218 | json.dumps(response_data, indent=4))) 219 | if response_data['data'][0]['result']['status'] == 1: 220 | logger.info("Successfully removed TXT record for %s", record_name) 221 | else: 222 | raise errors.PluginError("Error removing TXT record: %s" % response_data['data'][0]['result']['statusmsg']) 223 | 224 | def install_ssl(self, record_domain, cert_path, key_path, chain_path): 225 | """Install an SSL Certificate 226 | :param str record_domain: the domain name to upload to 227 | :param str cert_path: pointer to file of the cert 228 | :param int key_path: pointer to file of the key 229 | :param int chain_path: CA bundle 230 | :param int fullchain_path: 231 | """ 232 | 233 | default_subdomains = ['autodiscover', 'cpanel', 'cpcalendars', 'cpcontacts', 'mail', 'webdisk', 'webmail'] 234 | _, cpanel_name = self._get_zone_and_name(record_domain) 235 | if cpanel_name in default_subdomains: 236 | return 237 | 238 | data = self.data.copy() 239 | data['cpanel_jsonapi_module'] = 'SSL' 240 | data['cpanel_jsonapi_func'] = 'installssl' 241 | data['domain'] = record_domain 242 | data['crt'] = open(cert_path).read() 243 | data['key'] = open(key_path).read() 244 | data['cabundle'] = open(chain_path).read() 245 | 246 | response = urlopen( 247 | Request( 248 | "%s?%s" % (self.request_url, urlencode(data)), 249 | headers=self.headers 250 | ) 251 | ) 252 | response_data = json.load(response)['cpanelresult'] 253 | 254 | logger.debug("install_ssl: url='%s', data='%s', response data='%s'" % ( 255 | self.request_url, json.dumps(data, indent=4), 256 | json.dumps(response_data, indent=4))) 257 | if response_data['data'][0]['result'] == 1: 258 | logger.info("Successfully installed the SSL certificate for %s", record_domain) 259 | else: 260 | raise errors.PluginError("Error installing the SSL certificate for %s : %s" % (record_domain, response_data['data'][0]['output'])) 261 | 262 | def _get_zone_and_name(self, record_domain): 263 | """Find a suitable zone for a domain 264 | :param str record_name: the domain name 265 | :returns: (the zone, the name in the zone) 266 | :rtype: tuple 267 | """ 268 | cpanel_zone = '' 269 | cpanel_name = '' 270 | 271 | data = self.data.copy() 272 | data['cpanel_jsonapi_module'] = 'ZoneEdit' 273 | data['cpanel_jsonapi_func'] = 'fetchzones' 274 | 275 | response = urlopen( 276 | Request( 277 | "%s?%s" % (self.request_url, urlencode(data)), 278 | headers=self.headers 279 | ) 280 | ) 281 | response_data = json.load(response)['cpanelresult'] 282 | logger.debug("_get_zone_and_name: url='%s', data='%s', response data='%s'" % ( 283 | self.request_url, json.dumps(data, indent=4), 284 | json.dumps(response_data, indent=4))) 285 | matching_zones = { 286 | zone for zone in response_data['data'][0]['zones'] 287 | if (record_domain == zone or record_domain.endswith('.' + zone)) 288 | and response_data['data'][0]['zones'][zone] 289 | } 290 | if matching_zones: 291 | cpanel_zone = max(matching_zones, key = len) 292 | cpanel_name = record_domain[:-len(cpanel_zone)-1] 293 | else: 294 | raise errors.PluginError("Could not get the zone for %s. Is this name in a zone managed in cPanel?" % record_domain) 295 | 296 | return (cpanel_zone, cpanel_name) 297 | 298 | def _get_record_line(self, cpanel_zone, record_name, record_content, record_ttl): 299 | """Find the line numbers of a record a zone 300 | :param str cpanel_zone: the zone of the record 301 | :param str record_name: the name in the zone of the record 302 | :param str record_content: the content of the record 303 | :param str cpanel_ttl: the ttl of the record 304 | :returns: the line number and all it's duplicates 305 | :rtype: list 306 | """ 307 | record_lines = [] 308 | 309 | data = self.data.copy() 310 | data['cpanel_jsonapi_module'] = 'ZoneEdit' 311 | data['cpanel_jsonapi_func'] = 'fetchzone_records' 312 | data['domain'] = cpanel_zone 313 | data['name'] = record_name + '.' if not record_name.endswith('.') else '' 314 | data['type'] = 'TXT' 315 | data['txtdata'] = record_content 316 | data['ttl'] = record_ttl 317 | 318 | response = urlopen( 319 | Request( 320 | "%s?%s" % (self.request_url, urlencode(data)), 321 | headers=self.headers 322 | ) 323 | ) 324 | response_data = json.load(response)['cpanelresult'] 325 | logger.debug("_get_record_line: url='%s', data='%s', response data='%s'" % ( 326 | self.request_url, json.dumps(data, indent=4), 327 | json.dumps(response_data, indent=4))) 328 | record_lines = [int(d['line']) for d in response_data['data']] 329 | 330 | return record_lines 331 | 332 | # vim: set ts=4 sw=4: 333 | -------------------------------------------------------------------------------- /credentials.ini.exemple: -------------------------------------------------------------------------------- 1 | # The url cPanel url 2 | # include the scheme and the port number (usually 2083 for https) 3 | certbot_dns_cpanel:cpanel_url = https://cpanel.exemple.com:2083 4 | 5 | # The cPanel username 6 | certbot_dns_cpanel:cpanel_username = user 7 | 8 | # The cPanel password 9 | certbot_dns_cpanel:cpanel_password = hunter2 10 | 11 | # The cPanel API Token 12 | certbot_dns_cpanel:cpanel_token = EUTQ793EY7MIRX4EMXXXXXXXXXXOX4JF 13 | 14 | # You only need to configure API Token or Password. If you supply both, the API Token will be used 15 | 16 | # vi:syntax=ini 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from setuptools import find_packages 3 | 4 | version = '0.4.0' 5 | 6 | with open('README.md') as f: 7 | readme = f.read() 8 | 9 | setup( 10 | name='certbot-dns-cpanel', 11 | version=version, 12 | description='certbot plugin to allow acme dns-01 authentication & installation of a name managed in cPanel.', 13 | long_description=readme, 14 | long_description_content_type='text/markdown', 15 | url='https://github.com/badjware/certbot-dns-cpanel', 16 | author='Massaki Archambault', 17 | author_email='badjware@massaki.ca', 18 | license='Apache Licence 2.0', 19 | packages=find_packages(), 20 | classifiers=[ 21 | 'Development Status :: 3 - Alpha', 22 | 'Environment :: Plugins', 23 | 'Intended Audience :: System Administrators', 24 | 'License :: OSI Approved :: Apache Software License', 25 | 'Operating System :: POSIX :: Linux', 26 | 'Programming Language :: Python', 27 | 'Topic :: Internet :: WWW/HTTP', 28 | 'Topic :: Security', 29 | 'Topic :: System :: Installation/Setup', 30 | 'Topic :: System :: Networking', 31 | 'Topic :: System :: Systems Administration', 32 | 'Topic :: Utilities', 33 | ], 34 | keywords='certbot letsencrypt cpanel dns-01 plugin', 35 | install_requires=[ 36 | 'certbot<=1.32,>=0.40', 37 | 'acme<=1.32, >=0.40', 38 | 'zope.interface', 39 | ], 40 | entry_points={ 41 | 'certbot.plugins': [ 42 | 'cpanel = certbot_dns_cpanel.dns_cpanel:CpanelConfigurator', 43 | ], 44 | }, 45 | ) 46 | --------------------------------------------------------------------------------