#!/usr/bin/env bash # # dynadot-dns.sh — Dynadot DNS-01 challenge hook for acme.sh / certbot # # Dynadot API3 exposes only `get_dns` (read) and `set_dns2` (overwrite or # append). There is NO single-record delete, so: # * deploy : get_dns -> snapshot to state file, then APPEND the challenge # TXT at _acme-challenge. (add_dns_to_current_setting=1). # * clean : restore the snapshot via a full set_dns2 overwrite. # # This makes every DNS write reversible: the pre-challenge record set is the # same before deploy and after clean, so the hook never depends on manually # reconstructing the zone. # # SECRETS: the Dynadot API key must come from env DYNANDOT_API_KEY # (or DYNADOT_KEY). It is NEVER embedded in this file. # # Usage (acme.sh, DNS alias mode not required): # export DYNANDOT_API_KEY=... # acme.sh --issue --dns dns_dynadot -d mail.produktor.io \ # --challenge-alias '' ... # # or as certbot manual hooks: # certbot certonly --manual --preferred-challenges dns \ # --manual-auth-hook "dynadot-dns.sh deploy" \ # --manual-cleanup-hook "dynadot-dns.sh clean" ... # # Domain is derived from the first arg of CERTBOT_DOMAIN / ACME env, or # overridden with DYNADOT_DOMAIN. # set -euo pipefail API="${DYNANDOT_API:-https://api.dynadot.com/api3.json}" KEY="${DYNANDOT_API_KEY:-${DYNADOT_KEY:-}}" DOMAIN="${DYNADOT_DOMAIN:-}" STATE_DIR="${DYNADOT_STATE_DIR:-${TMPDIR:-/tmp}/dynadot-dns}" SNAPSHOT="${STATE_DIR}/snapshot.json" if [[ -z "${KEY}" ]]; then echo "FAIL: DYNANDOT_API_KEY unset" >&2 exit 1 fi # certbot sets CERTBOT_DOMAIN; acme.sh exposes the token via ACME_ env but the # domain is passed differently. Allow explicit first positional override. _cmd="${1:-}" if [[ "${_cmd}" == "deploy" || "${_cmd}" == "clean" ]]; then # third arg = record token, fourth = domain (optional) TOKEN="${2:-}" DOMAIN="${4:-${DOMAIN:-${CERTBOT_DOMAIN:-${DYNADOT_DOMAIN:-}}}}" else _cmd="${CERTBOT_AUTH_OUTPUT:+deploy}" # fallback shape, unused TOKEN="${CERTBOT_VALIDATION:-${ACME_CERTBOT_VALIDATION:-}}" fi TOKEN="${2:-${TOKEN:-${CERTBOT_VALIDATION:-}}}" if [[ -z "${_cmd}" ]]; then echo "FAIL: usage: dynadot-dns.sh [domain]" >&2 exit 2 fi if [[ "${_cmd}" == "deploy" && -z "${TOKEN}" ]]; then echo "FAIL: deploy needs the challenge token" >&2 exit 2 fi if [[ -z "${DOMAIN}" ]]; then echo "FAIL: no domain (set DYNADOT_DOMAIN)" >&2 exit 2 fi # Full DNS-01 challenge host for a subdomain: _acme-challenge. # e.g. mail.produktor.io -> sub "_acme-challenge.mail", apex produktor.io. # For the apex domain the subhost is just "_acme-challenge". if [[ "${DOMAIN}" == *.*.* ]]; then APEX="${DOMAIN#*.}" SUBHOST="_acme-challenge.${DOMAIN%%.*}" else APEX="${DOMAIN}" SUBHOST="_acme-challenge" fi _get_dns() { curl -sS --max-time 40 -G "${API}" \ --data-urlencode "key=${KEY}" \ --data-urlencode "command=get_dns" \ --data-urlencode "domain=${APEX}" } # Translate a get_dns JSON blob into set_dns2 append params for ONE record. _append_txt() { local subhost="$1" value="$2" curl -sS --max-time 40 -G "${API}" \ --data-urlencode "key=${KEY}" \ --data-urlencode "command=set_dns2" \ --data-urlencode "domain=${APEX}" \ --data-urlencode "subdomain0=${subhost}" \ --data-urlencode "sub_record_type0=txt" \ --data-urlencode "sub_record0=${value}" \ --data-urlencode "add_dns_to_current_setting=1" \ --data-urlencode "ttl=300" } deploy() { mkdir -p "${STATE_DIR}" echo "== snapshotting current DNS for ${APEX} ==" >&2 local snap snap="$(_get_dns)" if ! echo "${snap}" | grep -q '"ResponseCode":0\|"ResponseCode":"0"'; then echo "FAIL: get_dns did not return ResponseCode 0; aborting deploy (zone untouched)" >&2 exit 4 fi echo "${snap}" > "${SNAPSHOT}" echo "== appending TXT ${SUBHOST} = ${TOKEN} ==" >&2 local resp resp="$(_append_txt "${SUBHOST}" "${TOKEN}")" echo "${resp}" >&2 if [[ "${resp}" == *'"ResponseCode":"0"'* || "${resp}" == *'"ResponseCode":0'* ]]; then echo "OK appended" >&2 else echo "WARN: append response did not confirm success" >&2 fi } clean() { if [[ ! -f "${SNAPSHOT}" ]]; then echo "WARN: no snapshot at ${SNAPSHOT}; nothing to restore" >&2 exit 0 fi echo "== restoring DNS for ${APEX} from snapshot ==" >&2 # Reconstruct set_dns2 params from the snapshot via get_dns-style JSON. # We pass the snapshot straight back as an overwrite by re-deriving records. local jqbin jqbin="$(command -v jq || command -v yq || echo '')" if [[ -z "${jqbin}" ]]; then echo "FAIL: need jq or yq to restore snapshot" >&2 exit 3 fi local snap; snap="$(cat "${SNAPSHOT}")" # Build curl args from snapshot. Fields: main_record_typeN/main_recordN/... # and subdomainN/sub_record_typeN/sub_recordN, dropping any _acme-challenge. local args=() args+=(--data-urlencode "key=${KEY}") args+=(--data-urlencode "command=set_dns2") args+=(--data-urlencode "domain=${APEX}") local i=0 # main records — set_dns2 wants LOWERCASE record types, get_dns returns # UPPERCASE ("A"/"MX"/"TXT"/"CNAME"), so downcase before sending back. while IFS=$'\t' read -r t v x; do [[ -z "${t}" ]] && continue args+=(--data-urlencode "main_record_type${i}=${t,,}") args+=(--data-urlencode "main_record${i}=${v}") if [[ -n "${x}" ]]; then args+=(--data-urlencode "main_recordx${i}=${x}"); fi i=$((i+1)) done < <(echo "${snap}" | "${jqbin}" -r ' .GetDnsResponse.GetDns.NameServerSettings.MainDomains[]? | [.RecordType, .Value, (.Value2 // "")] | @tsv' 2>/dev/null || true) local s=0 while IFS=$'\t' read -r sub t v x; do [[ -z "${sub}" ]] && continue # skip challenge records [[ "${sub}" == _acme-challenge* ]] && continue args+=(--data-urlencode "subdomain${s}=${sub}") args+=(--data-urlencode "sub_record_type${s}=${t,,}") args+=(--data-urlencode "sub_record${s}=${v}") if [[ -n "${x}" ]]; then args+=(--data-urlencode "sub_recordx${s}=${x}"); fi s=$((s+1)) done < <(echo "${snap}" | "${jqbin}" -r ' .GetDnsResponse.GetDns.NameServerSettings.SubDomains[]? | [.Subhost, .RecordType, .Value, (.Value2 // "")] | @tsv' 2>/dev/null || true) args+=(--data-urlencode "ttl=300") local resp resp="$(curl -sS --max-time 60 -G "${API}" "${args[@]}")" echo "${resp}" >&2 if [[ "${resp}" == *'"ResponseCode":"0"'* || "${resp}" == *'"ResponseCode":0'* ]]; then rm -f "${SNAPSHOT}" echo "OK restored" >&2 else echo "FAIL: snapshot restore rejected by API; snapshot kept at ${SNAPSHOT}; zone may need manual attention" >&2 exit 5 fi } case "${_cmd}" in deploy) deploy ;; clean) clean ;; *) echo "FAIL: unknown cmd ${_cmd}" >&2; exit 2 ;; esac