- scripts/dynadot-dns.sh: DNS-01 hook (snapshot -> append TXT -> restore); set_dns2 wants lowercase record types, ResponseCode checks on deploy/clean - compose: SSL_TYPE=letsencrypt, mount tls/letsencrypt/<domain> -> /etc/letsencrypt/live/<domain>; drop-in jail.d/ignoreip.local so the postfix jail can't ban the docker bridge gateway (host self-DoS) - cert issued for mail.produktor.io (Let's Encrypt, ECDSA); renewal via acme.sh cron + reloadcmd (postfix/dovecot reload)
192 lines
6.6 KiB
Bash
Executable File
192 lines
6.6 KiB
Bash
Executable File
#!/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.<sub> (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 <deploy|clean> <token> [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.<subdomain>
|
|
# 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
|