diff --git a/compose.yaml b/compose.yaml index 066c579..9fa263a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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 diff --git a/config/fail2ban/ignoreip.conf b/config/fail2ban/ignoreip.conf new file mode 100644 index 0000000..647e7bb --- /dev/null +++ b/config/fail2ban/ignoreip.conf @@ -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 diff --git a/scripts/dynadot-dns.sh b/scripts/dynadot-dns.sh new file mode 100755 index 0000000..0fccf20 --- /dev/null +++ b/scripts/dynadot-dns.sh @@ -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. (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