Last active
June 3, 2024 17:11
-
-
Save Tugzrida/06b9c6f49683ebc30c9fcf2ed11a399a to your computer and use it in GitHub Desktop.
Certbot Cloudflare DNS challenge hook script
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# v0.4 Created by Tugzrida(https://gist.github.com/Tugzrida) | |
# Hook script for obtaining certificates through Certbot via Cloudflare DNS-01 challenge. | |
# Offers more flexibility for Cloudflare authentication than the certbot-dns-cloudflare plugin. | |
# Note that this script is not actively maintained or guaranteed to work consistently. | |
# Use in prod at your own risk and with adequate monitoring! | |
# Begin by listing the Cloudflare zones(domains) you with to obtain certificates for in the `zones` dict below, | |
# along with Cloudflare API tokens authorised to edit DNS on those zones. Also see the example dict for the CNAME setup option. | |
# To get certs using this script: | |
# certbot certonly --manual --preferred-challenges=dns --manual-auth-hook /path/to/certbot-cloudflare-hook.py --manual-cleanup-hook /path/to/certbot-cloudflare-hook.py -d example.com | |
# You'll also need to handle configuring your server software to point to the cert, and reloading it | |
# after renewals, possibly with a script in /etc/letsencrypt/renewal-hooks/post/ | |
# Certbot stores the path to this script in the renewal conf of each certificate, so if you move me, | |
# you'll need to update the path in each of the confs in /etc/letsencrypt/renewal/ | |
zones = { | |
# # Basic setup - this script edits records on the certificate domains: | |
# "example.com": {"token": "Token with Zone:DNS:Edit permissions for example.com"}, | |
# | |
# # CNAME setup - the _acme-challenge name of certificate domains is CNAMEd(CNAME record must be "grey-clouded") to another domain, which this script edits: | |
# "example.com": {"record_name": "example.com._acme-challenge.other-domain.com", "token": "Token with Zone:DNS:Edit permissions for other-domain.com"} | |
} | |
import os, json | |
from time import sleep | |
from urllib.request import urlopen, Request | |
from urllib.error import HTTPError, URLError | |
def cfAPI(endpoint, token, **kwargs): | |
try: | |
req = urlopen( | |
Request(f"https://api.cloudflare.com/client/v4/{endpoint}", | |
headers={ | |
"Authorization": f"Bearer {token}", | |
"Content-Type": "application/json; charset=utf-8" | |
}, | |
**kwargs | |
) | |
) | |
except HTTPError as e: | |
err = json.load(e)["errors"][0] | |
hints = { | |
6003: "Make sure you copied the whole token", | |
10000: "Ensure the token and token permissions are correct" | |
} | |
raise Exception("Cloudflare API Error: {} {}.{}".format(err["code"], err["message"], f' ({hints[err["code"]]})' if err["code"] in hints else "")) from None | |
except URLError as e: | |
raise Exception("Could not reach Cloudflare API!") from e | |
return json.load(req) | |
matchZone = lambda recName, zones: max((z for z in zones if recName == z or recName.endswith(f".{z}")), key=len, default=False) | |
def getZoneID(record_name, token): | |
zoneIDs = { | |
zone["name"]: zone["id"] for zone in cfAPI("zones", token)["result"] | |
} | |
return zoneIDs.get(matchZone(record_name, zoneIDs), False) | |
if "CERTBOT_DOMAIN" not in os.environ: | |
raise SystemExit("It doesn't look like this script was called from certbot") | |
if "CERTBOT_AUTH_OUTPUT" not in os.environ: | |
# Do auth | |
CERT_DOMAIN = os.environ["CERTBOT_DOMAIN"] | |
VALIDATION_TOKEN = os.environ["CERTBOT_VALIDATION"] | |
REMAINING_CHALLENGES = os.environ["CERTBOT_REMAINING_CHALLENGES"] | |
# Find the longest matching zone for this cert domain | |
matched_zone = matchZone(CERT_DOMAIN, zones) | |
if not matched_zone: | |
raise SystemExit(f"The zone of {CERT_DOMAIN} is not present in {os.path.abspath(__file__)}. Please add the Cloudflare zone to the `zones` dict in that script.") | |
# Get record_name from conf for CNAME operation, or default to standard _acme-challenge | |
record_name = zones[matched_zone].get("record_name", f"_acme-challenge.{CERT_DOMAIN}") | |
# Get zone id for record_name from Cloudflare | |
zone_id = getZoneID(record_name, zones[matched_zone].get("token")) | |
if not zone_id: | |
raise SystemExit(f"The zone of {record_name} doesn't exist in the Cloudflare account, or the API token doesn't have permission to access it.") | |
# Add record | |
res = cfAPI(f"zones/{zone_id}/dns_records", | |
token=zones[matched_zone]["token"], | |
data=json.dumps({ | |
"type": "TXT", | |
"name": record_name, | |
"content": VALIDATION_TOKEN, | |
"ttl": 60 | |
}).encode("utf-8") | |
) | |
# Output details for removing the record later | |
print(json.dumps({ | |
"zone_id": zone_id, | |
"record_id": res["result"]["id"], | |
"matched_zone": matched_zone | |
})) | |
# Wait for propagation | |
if REMAINING_CHALLENGES == "0": sleep(10) | |
else: | |
# Do cleanup | |
try: | |
# Load details passed from adding the record | |
addedRecord = json.loads(os.environ["CERTBOT_AUTH_OUTPUT"]) | |
# Remove record | |
res = cfAPI(f'zones/{addedRecord["zone_id"]}/dns_records/{addedRecord["record_id"]}', | |
token=zones[addedRecord["matched_zone"]]["token"], | |
method="DELETE" | |
) | |
except (json.decoder.JSONDecodeError, KeyError) as e: | |
raise SystemExit("Error preparing to remove record. Maybe adding the record wasn't successful.") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment