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)
This commit is contained in:
+4
-4
@@ -14,16 +14,16 @@ services:
|
||||
- ./data/mail-state/:/var/mail-state/
|
||||
- ./data/mail-logs/:/var/log/mail/
|
||||
- ./config/:/tmp/docker-mailserver/
|
||||
- ./tls/mail.key:/tmp/docker-mailserver/ssl/mail.produktor.io-key.pem:ro
|
||||
- ./tls/mail.crt:/tmp/docker-mailserver/ssl/mail.produktor.io-cert.pem:ro
|
||||
- ./tls/mail.crt:/tmp/docker-mailserver/ssl/demoCA/cacert.pem:ro
|
||||
# fail2ban: never ban the docker bridge gateway / host LAN (self-DoS guard)
|
||||
- ./config/fail2ban/ignoreip.conf:/etc/fail2ban/jail.d/ignoreip.local:ro
|
||||
- ./tls/letsencrypt/mail.produktor.io:/etc/letsencrypt/live/mail.produktor.io:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
environment:
|
||||
- ENABLE_SPAMASSASSIN=1
|
||||
- ENABLE_CLAMAV=0
|
||||
- ENABLE_FAIL2BAN=1
|
||||
- ENABLE_POP3=0
|
||||
- SSL_TYPE=self-signed
|
||||
- SSL_TYPE=letsencrypt
|
||||
- PERMIT_DOCKER=none
|
||||
- ONE_DIR=1
|
||||
- SPOOF_PROTECTION=1
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Host-originated connections reach postfix/dovecot via the docker bridge
|
||||
# gateway. Without ignoring it, the postfix jail bans our own host as
|
||||
# collateral of port-25 scanner noise + local TLS probes (self-DoS: host
|
||||
# submission times out). Keep in sync with the compose network subnet.
|
||||
[DEFAULT]
|
||||
ignoreip = 127.0.0.1/8 192.168.32.0/24 192.168.1.0/24
|
||||
Executable
+191
@@ -0,0 +1,191 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user