├── .github ├── dependabot.yml └── workflows │ └── run-tests.yml ├── .gitignore ├── LICENSE.md ├── MANIFEST.in ├── README.md ├── hook.py ├── requirements.txt ├── test-requirements.txt ├── tests ├── __init__.py └── unit │ ├── __init__.py │ └── test__hook.py └── tox.ini /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Tests 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | python-version: ["3.10", "3.11", "3.12", "3.13"] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Set up Python ${{ matrix.python-version }} 23 | uses: actions/setup-python@v5 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | - name: Install dependencies 27 | run: | 28 | python -m pip install --upgrade pip 29 | pip install tox 30 | pip install -r requirements.txt 31 | - name: Test with pytest 32 | run: | 33 | tox 34 | 35 | automerge: 36 | needs: test 37 | if: ${{ github.event.pull_request.user.login == 'dependabot[bot]' }} 38 | runs-on: ubuntu-latest 39 | steps: 40 | - uses: actions/checkout@v4 41 | - name: Enable automerge for Dependabot PRs 42 | shell: bash 43 | env: 44 | GH_TOKEN: ${{ github.token }} 45 | PR_NUMBER: ${{ github.event.pull_request.number }} 46 | run: | 47 | gh pr merge --squash --auto "$PR_NUMBER" 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.python-version 2 | .tox/ 3 | *.egg-info 4 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Yashar Fakhari 2 | Copyright (c) 2016 kappataumu 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.md 2 | include requirements.txt 3 | include LICENSE 4 | 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudFlare hook for dehydrated 2 | 3 | This is a hook for the [Let's Encrypt](https://letsencrypt.org/) ACME client [dehydrated](https://github.com/dehydrated-io/dehydrated) that allows you to use [CloudFlare](https://www.cloudflare.com/) DNS records to respond to `dns-01` challenges. This script requires Python and as well as your CloudFlare account e-mail and API key (as environment variables). 4 | 5 | ## Table of Contents 6 | - [System Prerequisites](#system-prerequisites) 7 | - [Installation](#installation-and-usage-without-a-python-virtual-environment) 8 | - [Configuration](#configuration) 9 | - [Usage](#usage) 10 | - [Using Virtual Environments and Scripting](#installation-with-python-virtual-envs-and-bash-script-for-quick-re-runs) 11 | - [Testing](#testing) 12 | 13 | ## System Prerequisites 14 | 15 | - Python 3.x 16 | - pip 17 | - git 18 | 19 | ### WSL, Ubuntu, and potentially Debian prerequisites 20 | You may need to install the following two packages in addition to Python 3 if you run into issues during the Installation: 21 | 22 | ``` 23 | sudo apt install python3-pip python-is-python3 24 | ``` 25 | 26 | 27 | ## Installation and usage without a Python virtual environment 28 | It is highly recommanded to use Python Virtual Environmnets instead of the installation and usage steps in this section. See [Installation with Python virtual envs and bash script for quick re-runs](#installation-with-python-virtual-envs-and-bash-script-for-quick-re-runs) for more information. 29 | 30 | Clone the code repo: 31 | ``` 32 | $ cd ~ 33 | $ git clone https://github.com/dehydrated-io/dehydrated 34 | $ cd dehydrated 35 | $ mkdir hooks 36 | $ git clone https://github.com/SeattleDevs/letsencrypt-cloudflare-hook hooks/cloudflare 37 | ``` 38 | 39 | Install required python packages: 40 | ``` 41 | $ pip3 install -r hooks/cloudflare/requirements.txt 42 | ``` 43 | 44 | 45 | ### Configuration 46 | 47 | An API token from your Cloudflare account is required. If you are currently using an email and your Cloudflare account's global API Key for authentication, please migrate to using an API token. API tokens are more secure as they follow the principles of least privilege and reduce the potential impact of malicious actors if your account key is compromised. To create an API token for this hook, go to https://dash.cloudflare.com/profile/api-tokens (API token page under your Cloudflare "My Profile"). Then, click on "Create API Token" to start. 48 | 49 | - Type a name for your API token for easy identification, such as "Dehydrated Let's Encrypt Hook" 50 | - Add Read permission for Zone -> Zone 51 | - Add Edit permission for Zone -> DNS 52 | - If you have multiple domains in your account, you can use Zone inclusion or exclusion to limit the API token's access to them 53 | - It is highly recommended to limit the API access to your IP address or subnet using the Client IP Address Filtering 54 | - Once you have completed the above steps, press the "Continue to summary" button and then "Create Token." 55 | 56 | Your account's CloudFlare email and API key are expected to be in the environment, you can set the enviornment variable if you need to in bash using: 57 | 58 | ``` 59 | $ export CF_API_TOKEN='zzle8imobfxpg50sdb3c' 60 | ``` 61 | 62 | You can supply multiple API keys by separating them with one or more spaces. API keys will be tried in the order given, until one is found that has permissions for the relevant domain. 63 | Leading, trailing, and extra spaces are ignored, so you can vertically align credential pairs for easy reading: 64 | 65 | ``` 66 | $ export CF_API_TOKEN='zzle8imobfxpg50sdb3c txjo6e779dxhpa8yofft' 67 | ``` 68 | 69 | Optionally, you can specify the DNS servers to be used for propagation checking via the `CF_DNS_SERVERS` environment variable. The following are the IPs for the Cloudflare DNS Servers in case if you would like to use them instead of your client's default DNS configs: 70 | 71 | ``` 72 | $ export CF_DNS_SERVERS='1.1.1.1 1.0.0.1' 73 | ``` 74 | 75 | If you experience problems with DNS propagation, increasing the time (in seconds) this hook waits for things to settle down after setting the DNS records, may help. The default is 10. 76 | 77 | ``` 78 | $ export CF_SETTLE_TIME='30' 79 | ``` 80 | 81 | If you want more information about what is going on while the hook is running: 82 | 83 | ``` 84 | $ export CF_DEBUG='true' 85 | ``` 86 | 87 | Alternatively, these statements can be placed in `dehydrated/config`, which is automatically sourced by `dehydrated` on startup: 88 | 89 | ``` 90 | echo "export CF_API_TOKEN=zzle8imobfxpg50sdb3c" >> config 91 | echo "export CF_DEBUG=true" >> config 92 | ``` 93 | 94 | 95 | ### Usage 96 | 97 | ``` 98 | $ ./dehydrated -c -d example.com -t dns-01 -k 'hooks/cloudflare/hook.py' 99 | # 100 | # !! WARNING !! No main config file found, using default config! 101 | # 102 | Processing example.com 103 | + Signing domains... 104 | + Creating new directory /home/user/dehydrated/certs/example.com ... 105 | + Generating private key... 106 | + Generating signing request... 107 | + Requesting challenge for example.com... 108 | + CloudFlare hook executing: deploy_challenge 109 | + DNS not propagated, waiting 30s... 110 | + DNS not propagated, waiting 30s... 111 | + Responding to challenge for example.com... 112 | + CloudFlare hook executing: clean_challenge 113 | + Challenge is valid! 114 | + Requesting certificate... 115 | + Checking certificate... 116 | + Done! 117 | + Creating fullchain.pem... 118 | + CloudFlare hook executing: deploy_cert 119 | + ssl_certificate: /home/user/dehydrated/certs/example.com/fullchain.pem 120 | + ssl_certificate_key: /home/user/dehydrated/certs/example.com/privkey.pem 121 | + Done! 122 | ``` 123 | 124 | 125 | ## Installation with Python virtual envs and bash script for quick re-runs 126 | The following will install dehydrated in `cert_workspace` folder under your home path. The last line sets up a Python virtual env named dehydrated_env. 127 | 128 | ``` 129 | $ cd ~ 130 | $ mkdir cert_workspace 131 | $ cd cert_workspace 132 | $ git clone https://github.com/dehydrated-io/dehydrated 133 | $ cd dehydrated 134 | $ mkdir hooks 135 | $ git clone https://github.com/SeattleDevs/letsencrypt-cloudflare-hook hooks/cloudflare 136 | $ python3 -m venv dehydrated_env 137 | ``` 138 | 139 | ### Initialize the python environment 140 | Activate the virtual env and install dependencies: 141 | 142 | ``` 143 | source dehydrated_env/bin/activate 144 | $ (dehydrated_env) pip3 install -r hooks/cloudflare/requirements.txt 145 | ``` 146 | 147 | ### Usage with a bash script 148 | You can take a shortcut by creating a bash script (such as `domaincert.sh` in `~/cert_workspace`) as following to generate your certificate quickly since you need to regenerate your certificates once every 90 days. Replace CF_EMAIL (your Cloudflare email), CF_KEY (your Cloudflare API Key), and DOMAIN variables with your own info. The following assumes that the bash script is stored where you created the git clone folder for dehydrated to reduce the chances of accidentally checking in the bash script into a git repo because it is a good security practice not to store credentials in git repos. 149 | 150 | ``` 151 | #!/bin/bash 152 | 153 | export CF_API_TOKEN='zzle8imobfxpg50sdb3c' 154 | export DOMAIN='my.domain.com' 155 | 156 | export CF_DNS_SERVERS='1.1.1.1 1.0.0.1' 157 | # export CF_DEBUG='true' 158 | 159 | dehydrated/dehydrated -c -d $DOMAIN -t dns-01 -k 'dehydrated/hooks/cloudflare/hook.py' 160 | 161 | cp dehydrated/certs/$DOMAIN/privkey.pem $DOMAIN.letsencrypt.key 162 | cp dehydrated/certs/$DOMAIN/fullchain.pem $DOMAIN.letsencrypt.crt 163 | 164 | exit 0 165 | ``` 166 | 167 | Assuming that you created a bash script as `~/cert_workspace/domaincert.sh` , you can run it with the following. Note that the first time you run dehydrated, it may prompt you to accept its terms. Please read and follow with any such instructions that it may provide, and then re-run your `domaincert.sh` script to generate the certificate. 168 | 169 | ``` 170 | $ (dehydrated_env) cd ~/cert_workspace 171 | $ (dehydrated_env) ./domaincert.sh 172 | ``` 173 | 174 | 175 | ### Re-run (every 90 days or 8x days) 176 | This is what you need to re-run before the 90 day expiration of your certificate to generate a new certificate assuming that you set up your installation as above. 177 | 178 | ``` 179 | $ cd ~/cert_workspace/dehydrated 180 | $ source dehydrated_env/bin/activate 181 | $ (dehydrated_env) cd ~/cert_workspace 182 | $ (dehydrated_env) ./domaincert.sh 183 | ``` 184 | 185 | ### Update and Re-run 186 | If you want to update the scripts to the latest version (i.e. because Python, Let's Encrypt or Cloudflare changed their APIs causing errors when you re-run the script, or if there is a security update), run the following: 187 | 188 | ``` 189 | $ cd ~/cert_workspace/dehydrated 190 | $ git pull 191 | $ cd hooks/cloudflare 192 | $ git pull 193 | $ cd ../.. 194 | $ source dehydrated_env/bin/activate 195 | $ (dehydrated_env) pip3 install -r hooks/cloudflare/requirements.txt 196 | ``` 197 | 198 | and then re-generate your certifcate as before: 199 | 200 | ``` 201 | $ (dehydrated_env) cd ~/cert_workspace 202 | $ (dehydrated_env) ./domaincert.sh 203 | ``` 204 | 205 | 206 | ## Testing 207 | ### Unit Tests 208 | If you are making changes to the code, run the unit tests with `tox` to make sure your changes aren't breaking the hook. 209 | 210 | ``` 211 | $ (dehydrated_env) cd hooks/cloudflare 212 | $ (dehydrated_env) pip install tox 213 | $ (dehydrated_env) tox 214 | ``` 215 | 216 | ### Integration Testing 217 | If you are making changes to the code, you can do a full test with real calls through dehydrated and Let's Encrypt's test servers. The test servers won't issue a valid certificate, but have a higher rate limit which allows you to test your hook changes without using up your production quota. 218 | 219 | 1. If you haven't used Let's Encrypt's test server before, you will need to accept its terms of service. Assuming you are aware of the terms and would like to accept them, you can do so using the following command: 220 | ``` 221 | $ ./dehydrated --register --accept-terms --ca letsencrypt-test 222 | ``` 223 | 224 | 2. You need to add `--ca letsencrypt-test --force --force-validation` to the dehydrated parameters when calling it. This ensures the use of the test servers, tells dehydrated to ignore the 30-day protection limit, and skips previously cached domain verifications with Cloudflare so the verification process is re-run. 225 | ``` 226 | $ ./dehydrated -c --ca letsencrypt-test --force --force-validation -d example.com -t dns-01 -k 'hooks/cloudflare/hook.py' 227 | ``` 228 | 229 | ## Further reading 230 | If you want some prose to go with the code, check out the relevant blog post here: [From StartSSL to Let's Encrypt, using CloudFlare DNS](http://kappataumu.com/articles/letsencrypt-cloudflare-dns-01-hook.html). 231 | -------------------------------------------------------------------------------- /hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import dns.exception 4 | import dns.resolver 5 | import logging 6 | import os 7 | import requests 8 | import sys 9 | import time 10 | 11 | from tld import get_fld 12 | 13 | logger = logging.getLogger(__name__) 14 | logger.addHandler(logging.StreamHandler(sys.stdout)) 15 | 16 | if os.environ.get('CF_DEBUG'): 17 | logger.setLevel(logging.DEBUG) 18 | else: 19 | logger.setLevel(logging.INFO) 20 | 21 | # Initialize empty headers list 22 | CF_HEADERS = [] 23 | 24 | # Check for API token first 25 | try: 26 | CF_HEADERS.extend([{ 27 | 'Authorization': f'Bearer {api_token}', 28 | 'Content-Type': 'application/json', 29 | } for api_token in os.environ['CF_API_TOKEN'].split()]) 30 | except KeyError: 31 | # If no API token, try email/key authentication 32 | try: 33 | CF_HEADERS.extend([{ 34 | 'X-Auth-Email': email, 35 | 'X-Auth-Key': api_key, 36 | 'Content-Type': 'application/json', 37 | } for email, api_key in zip(os.environ['CF_EMAIL'].split(), os.environ['CF_KEY'].split())]) 38 | except KeyError: 39 | logger.error(" + Unable to locate Cloudflare credentials in environment!") 40 | logger.error(" + Please set the CF_API_TOKEN environment variable with your API token.") 41 | sys.exit(1) 42 | 43 | if not CF_HEADERS: 44 | logger.error(" + No valid Cloudflare credentials found in environment!") 45 | sys.exit(1) 46 | 47 | try: 48 | dns_servers = os.environ['CF_DNS_SERVERS'] 49 | dns_servers = dns_servers.split() 50 | except KeyError: 51 | dns_servers = False 52 | 53 | 54 | def _has_dns_propagated(domain_name, token): 55 | try: 56 | if dns_servers: 57 | custom_resolver = dns.resolver.Resolver() 58 | custom_resolver.nameservers = dns_servers 59 | dns_response = custom_resolver.resolve(domain_name, 'TXT') 60 | else: 61 | dns_response = dns.resolver.resolve(domain_name, 'TXT') 62 | 63 | for rdata in dns_response: 64 | if token in [b.decode('utf-8') for b in rdata.strings]: 65 | return True 66 | 67 | except dns.exception.DNSException as e: 68 | logger.debug(" + {0}. Retrying query...".format(e)) 69 | 70 | return False 71 | 72 | 73 | # https://api.cloudflare.com/#zone-list-zones 74 | def _get_zone_id(domain): 75 | tld = get_fld('http://' + domain) 76 | url = "https://api.cloudflare.com/client/v4/zones?name={0}".format(tld) 77 | for auth in CF_HEADERS: 78 | r = requests.get(url, headers=auth) 79 | r.raise_for_status() 80 | r = r.json().get('result',()) 81 | if r: 82 | return auth, r[0]['id'] 83 | if 'CF_API_TOKEN' in os.environ: 84 | logger.error(f"\033[91mERROR:\033[0m None of the provided API Tokens have the required permissions for the domain {tld}") 85 | else: 86 | logger.error(f"\033[91mERROR:\033[0m Domain {tld} not found in any Cloudflare account") 87 | sys.exit(1) 88 | 89 | # https://api.cloudflare.com/#dns-records-for-a-zone-dns-record-details 90 | def _get_txt_record_id(auth, zone_id, name, token): 91 | url = "https://api.cloudflare.com/client/v4/zones/{0}/dns_records?type=TXT&name={1}&content={2}".format(zone_id, name, token) 92 | r = requests.get(url, headers=auth) 93 | r.raise_for_status() 94 | try: 95 | record_id = r.json()['result'][0]['id'] 96 | except IndexError: 97 | logger.debug(" + Unable to locate record named {0}".format(name)) 98 | return 99 | 100 | return record_id 101 | 102 | 103 | # https://api.cloudflare.com/#dns-records-for-a-zone-create-dns-record 104 | def create_txt_record(args): 105 | domain, challenge, token = args 106 | logger.debug(' + Creating TXT record: {0} => {1}'.format(domain, token)) 107 | logger.debug(' + Challenge: {0}'.format(challenge)) 108 | auth, zone_id = _get_zone_id(domain) 109 | name = "{0}.{1}".format('_acme-challenge', domain) 110 | 111 | record_id = _get_txt_record_id(auth, zone_id, name, token) 112 | if record_id: 113 | logger.debug(" + TXT record exists, skipping creation.") 114 | return 115 | 116 | url = "https://api.cloudflare.com/client/v4/zones/{0}/dns_records".format(zone_id) 117 | payload = { 118 | 'type': 'TXT', 119 | 'name': name, 120 | 'content': token, 121 | 'ttl': 120, 122 | } 123 | r = requests.post(url, headers=auth, json=payload) 124 | r.raise_for_status() 125 | record_id = r.json()['result']['id'] 126 | logger.debug(" + TXT record created, CFID: {0}".format(record_id)) 127 | 128 | 129 | # https://api.cloudflare.com/#dns-records-for-a-zone-delete-dns-record 130 | def delete_txt_record(args): 131 | domain, token = args[0], args[2] 132 | if not domain: 133 | logger.info(" + http_request() error in letsencrypt.sh?") 134 | return 135 | 136 | auth, zone_id = _get_zone_id(domain) 137 | name = "{0}.{1}".format('_acme-challenge', domain) 138 | record_id = _get_txt_record_id(auth, zone_id, name, token) 139 | 140 | if record_id: 141 | url = "https://api.cloudflare.com/client/v4/zones/{0}/dns_records/{1}".format(zone_id, record_id) 142 | r = requests.delete(url, headers=auth) 143 | r.raise_for_status() 144 | logger.debug(" + Deleted TXT {0}, CFID {1}".format(name, record_id)) 145 | else: 146 | logger.debug(" + No TXT {0} with token {1}".format(name, token)) 147 | 148 | 149 | def deploy_cert(args): 150 | domain, privkey_pem, cert_pem, fullchain_pem, chain_pem, timestamp = args 151 | logger.debug(' + ssl_certificate: {0}'.format(fullchain_pem)) 152 | logger.debug(' + ssl_certificate_key: {0}'.format(privkey_pem)) 153 | return 154 | 155 | 156 | def unchanged_cert(args): 157 | return 158 | 159 | 160 | def invalid_challenge(args): 161 | domain, result = args[0], " ".join(args[1:]) 162 | logger.debug(' + invalid_challenge for {0}'.format(domain)) 163 | logger.debug(' + Full error: {0}'.format(result)) 164 | return 165 | 166 | 167 | def create_all_txt_records(args): 168 | settle_time = int(os.environ.get('CF_SETTLE_TIME', '10')) 169 | X = 3 170 | for i in range(0, len(args), X): 171 | create_txt_record(args[i:i+X]) 172 | # give it some time (default: 10 seconds) to settle down and avoid nxdomain caching 173 | logger.info(" + Settling down for {}s...".format(settle_time)) 174 | time.sleep(settle_time) 175 | for i in range(0, len(args), X): 176 | domain, token = args[i], args[i+2] 177 | name = "{0}.{1}".format('_acme-challenge', domain) 178 | while(_has_dns_propagated(name, token) == False): 179 | logger.info(" + DNS not propagated, waiting 30s...") 180 | time.sleep(30) 181 | 182 | 183 | def delete_all_txt_records(args): 184 | X = 3 185 | for i in range(0, len(args), X): 186 | delete_txt_record(args[i:i+X]) 187 | 188 | def startup_hook(args): 189 | if 'CF_API_TOKEN' in os.environ and ('CF_EMAIL' in os.environ or 'CF_KEY' in os.environ): 190 | print("\033[93m + Warning: Both CF_API_TOKEN and CF_EMAIL/CF_KEY environment variables are set.\n CF_EMAIL and CF_KEY environment variables are no longer needed for this script.\n You may consider removing them from your environment.\033[0m") 191 | elif 'CF_EMAIL' in os.environ or 'CF_KEY' in os.environ: 192 | print("\033[93m + Using Cloudflare account email/key authentication (CF_EMAIL and CF_KEY environment variables).\n We are planning to deprecate this authentication method. Please switch to API tokens for enhanced security.\n See the README for more information.\033[0m") 193 | return 194 | 195 | def exit_hook(args): 196 | return 197 | 198 | 199 | def main(argv): 200 | ops = { 201 | 'deploy_challenge': create_all_txt_records, 202 | 'clean_challenge' : delete_all_txt_records, 203 | 'deploy_cert' : deploy_cert, 204 | 'unchanged_cert' : unchanged_cert, 205 | 'invalid_challenge': invalid_challenge, 206 | 'startup_hook': startup_hook, 207 | 'exit_hook': exit_hook 208 | } 209 | if argv[0] in ops: 210 | logger.info(" + CloudFlare hook executing: {0}".format(argv[0])) 211 | ops[argv[0]](argv[1:]) 212 | 213 | if __name__ == '__main__': 214 | main(sys.argv[1:]) 215 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnspython==2.7.0 2 | requests==2.32.4 3 | six==1.17.0 4 | testresources==2.0.2 5 | tld==0.13.1 6 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | mock>=5.1.0 3 | requests-mock>=1.12.1 4 | testtools>=2.7.1 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeattleDevs/letsencrypt-cloudflare-hook/5fcafd45c6df04c45dfa569df2c25303ac9ae250/tests/__init__.py -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SeattleDevs/letsencrypt-cloudflare-hook/5fcafd45c6df04c45dfa569df2c25303ac9ae250/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test__hook.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from __future__ import division 3 | from __future__ import print_function 4 | from __future__ import unicode_literals 5 | 6 | import collections 7 | import json 8 | import os 9 | import re 10 | 11 | import mock 12 | import requests_mock 13 | from six.moves.urllib import parse as urlparse 14 | import testtools 15 | 16 | # Setup dummy environment variables so 'hook' can be imported 17 | os.environ['CF_EMAIL'] = "email@example'com" 18 | os.environ['CF_KEY'] = "a_cloudflare_example_key" 19 | 20 | import hook # noqa 21 | 22 | 23 | CF_API_HOST = "api.cloudflare.com" 24 | CF_API_PATH = "/client/v4" 25 | CF_API_SCHEME = "https" 26 | 27 | 28 | class TestBase(testtools.TestCase): 29 | def setUp(self): 30 | super(TestBase, self).setUp() 31 | def setup_api_token_auth(self): 32 | if 'CF_EMAIL' in os.environ: del os.environ['CF_EMAIL'] 33 | if 'CF_KEY' in os.environ: del os.environ['CF_KEY'] 34 | os.environ['CF_API_TOKEN'] = "a_cloudflare_api_token" 35 | self.expected_headers = { 36 | 'Content-Type': 'application/json', 37 | 'Authorization': 'Bearer a_cloudflare_api_token', 38 | } 39 | def setup_legacy_auth(self): 40 | if 'CF_API_TOKEN' in os.environ: del os.environ['CF_API_TOKEN'] 41 | os.environ['CF_EMAIL'] = "email@example.com" 42 | os.environ['CF_KEY'] = "a_cloudflare_example_key" 43 | self.expected_headers = { 44 | 'Content-Type': 'application/json', 45 | 'X-Auth-Email': "email@example.com", 46 | 'X-Auth-Key': 'a_cloudflare_example_key', 47 | } 48 | 49 | 50 | ExpectedRequestsData = collections.namedtuple( 51 | 'ExpectedRequestsData', ['method', 'path', 'query', 'json_body']) 52 | 53 | 54 | @requests_mock.Mocker() 55 | class TestRequestCallers(TestBase): 56 | 57 | def setUp(self): 58 | super(TestRequestCallers, self).setUp() 59 | self.matcher = re.compile(r'^https://api\.cloudflare\.com/client/v4/') 60 | 61 | def _validate_requests_calls(self, mock_request, expected_data_list): 62 | """Helper function to check values of calls to requests""" 63 | # Make sure our call count matches up with what we expect 64 | self.assertEqual(len(expected_data_list), mock_request.call_count) 65 | for index, expected_data in enumerate(expected_data_list): 66 | # Provide a bit more info if a test fails 67 | expected_str = "Info: {}".format(expected_data) 68 | request_obj = mock_request.request_history[index] 69 | parsed_url = urlparse.urlparse(request_obj.url) 70 | self.assertEqual(expected_data.method.upper(), 71 | request_obj.method) 72 | self.assertEqual(CF_API_SCHEME, parsed_url.scheme) 73 | self.assertEqual(CF_API_HOST, parsed_url.netloc) 74 | self.assertEqual( 75 | "{}/{}".format(CF_API_PATH, expected_data.path), 76 | parsed_url.path) 77 | self.assertEqual(expected_data.query, request_obj.qs, 78 | expected_str) 79 | if expected_data.json_body is not None: 80 | self.assertEqual(expected_data.json_body, 81 | json.loads(request_obj._request.body), 82 | expected_str) 83 | 84 | def test__get_zone_id(self, mock_request): 85 | expected_list = [ 86 | ExpectedRequestsData( 87 | method='get', 88 | path="zones", 89 | query={'name': ['example.com']}, 90 | json_body=None, 91 | ), 92 | ] 93 | mock_request.get(self.matcher, text=ZONE_RESPONSE) 94 | 95 | auth, result = hook._get_zone_id("example.com") 96 | 97 | expected_id = "023e105f4ecef8ad9ca31a8372d0c353" 98 | self.assertEqual(expected_id, result) 99 | 100 | self._validate_requests_calls(mock_request=mock_request, 101 | expected_data_list=expected_list) 102 | 103 | def test__get_txt_record_id_found(self, mock_request): 104 | expected_list = [ 105 | ExpectedRequestsData( 106 | method='get', 107 | path='zones/ZONE_ID/dns_records', 108 | query={'content': ['token'], 'name': ['example.com'], 109 | 'type': ['txt']}, 110 | json_body=None, 111 | ), 112 | ] 113 | mock_request.get(self.matcher, text=DNS_RECORDS_RESPONSE) 114 | 115 | result = hook._get_txt_record_id({}, "ZONE_ID", "example.com", "TOKEN") 116 | 117 | expected_id = "372e67954025e0ba6aaa6d586b9e0b59" 118 | self.assertEqual(expected_id, result) 119 | 120 | self._validate_requests_calls(mock_request=mock_request, 121 | expected_data_list=expected_list) 122 | 123 | def test__get_txt_record_id_not_found(self, mock_request): 124 | expected_list = [ 125 | ExpectedRequestsData( 126 | method='get', 127 | path="zones/ZONE_ID/dns_records", 128 | query={'content': ['token'], 'name': ['example.com'], 129 | 'type': ['txt']}, 130 | json_body=None, 131 | ), 132 | ] 133 | mock_request.get(self.matcher, text=DNS_RECORDS_RESPONSE_NOT_FOUND) 134 | 135 | result = hook._get_txt_record_id({}, "ZONE_ID", "example.com", "TOKEN") 136 | 137 | self.assertEqual(None, result) 138 | self._validate_requests_calls(mock_request=mock_request, 139 | expected_data_list=expected_list) 140 | 141 | @mock.patch.object(hook, '_get_txt_record_id', 142 | lambda auth, zone_id, name, token: None) 143 | @mock.patch.object(hook, '_get_txt_record_id', 144 | lambda auth, zone_id, name, token: None) 145 | def test_create_txt_record(self, mock_request): 146 | expected_list = [ 147 | ExpectedRequestsData( 148 | method='get', 149 | path="zones", 150 | query={'name': ['example.com']}, 151 | json_body=None, 152 | ), 153 | ExpectedRequestsData( 154 | method='post', 155 | path=("zones/023e105f4ecef8ad9ca31a8372d0c353/" 156 | "dns_records"), 157 | query={}, 158 | json_body={'content': 'TOKEN', 'type': 'TXT', 'ttl': 120, 159 | 'name': '_acme-challenge.example.com', 160 | }, 161 | ) 162 | ] 163 | mock_request.get(self.matcher, text=ZONE_RESPONSE) 164 | mock_request.post(self.matcher, text=CREATE_DNS_RECORD_RESPONSE) 165 | 166 | args = ['example.com', 'CHALLENGE', 'TOKEN'] 167 | result = hook.create_txt_record(args) 168 | 169 | self._validate_requests_calls(mock_request=mock_request, 170 | expected_data_list=expected_list) 171 | 172 | self.assertEqual(None, result) 173 | 174 | 175 | # Sample responses 176 | 177 | ZONE_RESPONSE = """ 178 | { 179 | "success": true, 180 | "errors": [ 181 | {} 182 | ], 183 | "messages": [ 184 | {} 185 | ], 186 | "result": [ 187 | { 188 | "id": "023e105f4ecef8ad9ca31a8372d0c353", 189 | "name": "example.com", 190 | "development_mode": 7200, 191 | "original_name_servers": [ 192 | "ns1.originaldnshost.com", 193 | "ns2.originaldnshost.com" 194 | ], 195 | "original_registrar": "GoDaddy", 196 | "original_dnshost": "NameCheap", 197 | "created_on": "2014-01-01T05:20:00.12345Z", 198 | "modified_on": "2014-01-01T05:20:00.12345Z", 199 | "owner": { 200 | "id": "7c5dae5552338874e5053f2534d2767a", 201 | "email": "user@example.com", 202 | "owner_type": "user" 203 | }, 204 | "permissions": [ 205 | "#zone:read", 206 | "#zone:edit" 207 | ], 208 | "plan": { 209 | "id": "e592fd9519420ba7405e1307bff33214", 210 | "name": "Pro Plan", 211 | "price": 20, 212 | "currency": "USD", 213 | "frequency": "monthly", 214 | "legacy_id": "pro", 215 | "is_subscribed": true, 216 | "can_subscribe": true 217 | }, 218 | "plan_pending": { 219 | "id": "e592fd9519420ba7405e1307bff33214", 220 | "name": "Pro Plan", 221 | "price": 20, 222 | "currency": "USD", 223 | "frequency": "monthly", 224 | "legacy_id": "pro", 225 | "is_subscribed": true, 226 | "can_subscribe": true 227 | }, 228 | "status": "active", 229 | "paused": false, 230 | "type": "full", 231 | "name_servers": [ 232 | "tony.ns.cloudflare.com", 233 | "woz.ns.cloudflare.com" 234 | ] 235 | } 236 | ], 237 | "result_info": { 238 | "page": 1, 239 | "per_page": 20, 240 | "count": 1, 241 | "total_count": 2000 242 | } 243 | } 244 | """ 245 | 246 | DNS_RECORDS_RESPONSE = """ 247 | { 248 | "success": true, 249 | "errors": [], 250 | "messages": [], 251 | "result": [ 252 | { 253 | "id": "372e67954025e0ba6aaa6d586b9e0b59", 254 | "type": "TXT", 255 | "name": "_acme-challenge.test.example.com", 256 | "content": "WyIlYaKOp62zaDu_JDKwfXVCnr4q4ntYtmkZ3y5BF2w", 257 | "proxiable": false, 258 | "proxied": false, 259 | "ttl": 120, 260 | "locked": false, 261 | "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", 262 | "zone_name": "example.com", 263 | "created_on": "2014-01-01T05:20:00.12345Z", 264 | "modified_on": "2014-01-01T05:20:00.12345Z", 265 | "data": {} 266 | } 267 | ], 268 | "result_info": { 269 | "page": 1, 270 | "per_page": 20, 271 | "count": 1, 272 | "total_count": 2000 273 | } 274 | } 275 | """ 276 | 277 | DNS_RECORDS_RESPONSE_NOT_FOUND = """ 278 | { 279 | "success": true, 280 | "errors": [], 281 | "messages": [], 282 | "result": [], 283 | "result_info": { 284 | "page": 1, 285 | "per_page": 20, 286 | "count": 1, 287 | "total_count": 2000 288 | } 289 | } 290 | """ 291 | 292 | CREATE_DNS_RECORD_RESPONSE = """ 293 | { 294 | "success": true, 295 | "errors": [ 296 | {} 297 | ], 298 | "messages": [ 299 | {} 300 | ], 301 | "result": { 302 | "id": "372e67954025e0ba6aaa6d586b9e0b59", 303 | "type": "A", 304 | "name": "example.com", 305 | "content": "1.2.3.4", 306 | "proxiable": true, 307 | "proxied": false, 308 | "ttl": 120, 309 | "locked": false, 310 | "zone_id": "023e105f4ecef8ad9ca31a8372d0c353", 311 | "zone_name": "example.com", 312 | "created_on": "2014-01-01T05:20:00.12345Z", 313 | "modified_on": "2014-01-01T05:20:00.12345Z", 314 | "data": {} 315 | } 316 | } 317 | """ 318 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | minversion = 1.8 3 | skipsdist = True 4 | envlist = py3 5 | 6 | [testenv] 7 | install_command = pip3 install {opts} {packages} 8 | usedevelop = True 9 | setenv = VIRTUAL_ENV={envdir} 10 | PYTHONDONTWRITEBYTECODE = 1 11 | LANGUAGE=en_US 12 | TESTS_DIR=./tests/unit/ 13 | deps = 14 | -r{toxinidir}/requirements.txt 15 | -r{toxinidir}/test-requirements.txt 16 | commands = {envbindir}/python -m unittest discover -v --top-level-directory {toxinidir} --start-directory {toxinidir}/tests/unit/ 17 | 18 | [flake8] 19 | show-source = True 20 | #ignore = E129 21 | exclude = .venv,.tox,dist,doc,*.egg,.update-venv 22 | 23 | [testenv:pep8] 24 | commands = 25 | flake8 {posargs} 26 | --------------------------------------------------------------------------------