Files
eSlider 7d874c5117 TLS: letsencrypt DNS-01 via Dynadot hook; fail2ban ignoreip guard (#114)
- 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)
2026-08-22 22:46:24 +01:00

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