Skip to content

Instantly share code, notes, and snippets.

@Tugzrida
Last active April 21, 2024 20:47
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Tugzrida/06b9c6f49683ebc30c9fcf2ed11a399a to your computer and use it in GitHub Desktop.
Save Tugzrida/06b9c6f49683ebc30c9fcf2ed11a399a to your computer and use it in GitHub Desktop.
Certbot Cloudflare DNS challenge hook script
#!/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