276 lines
8.8 KiB
Bash
276 lines
8.8 KiB
Bash
#!/usr/bin/env bash
|
||
#
|
||
# acme-dns docker setup script (production hardened)
|
||
# generates validated, secure configuration for joohoi/acme-dns
|
||
# https://github.com/joohoi/acme-dns
|
||
#
|
||
|
||
set -euo pipefail
|
||
ifs=$'\n\t'
|
||
|
||
# ======================
|
||
# configuration (edit these)
|
||
# ======================
|
||
setup_dir="/var/tmp/acme-dns-setup"
|
||
main_domain="ring-zero.co.uk" # your registered domain (e.g., yoursite.com)
|
||
acme_dns_subdomain="auth" # creates auth.yoursite.com
|
||
acme_dns_fqdn="${acme_dns_subdomain}.${main_domain}"
|
||
public_ip="192.237.223.112" # public server ip (must be reachable on udp/tcp 53)
|
||
admin_email="letsencrypt@${main_domain}" # soa contact (converted to dns format)
|
||
le_notification_email="letsencrypt@${main_domain}" # let's encrypt expiry notices
|
||
|
||
# api configuration
|
||
api_port="443" # 443 required for letsencrypt modes
|
||
api_tls_mode="letsencryptstaging" # none | letsencrypt | letsencryptstaging | cert
|
||
custom_tls_cert_privkey="/etc/acme-dns/certs/privkey.pem"
|
||
custom_tls_cert_fullchain="/etc/acme-dns/certs/fullchain.pem"
|
||
|
||
# operational
|
||
dns_port="53" # must be 53 for public dns delegation
|
||
log_level="info" # error | warning | info | debug
|
||
|
||
# Set cat type:
|
||
# Determine which command to use
|
||
if command -v batcat >/dev/null; then
|
||
COLOR_CAT="batcat --language=md --style=plain"
|
||
elif command -v bat >/dev/null; then
|
||
COLOR_CAT="bat --language=md --style=plain"
|
||
else
|
||
COLOR_CAT="cat"
|
||
fi
|
||
|
||
# ======================
|
||
# validation & safety
|
||
# ======================
|
||
validate_domain() {
|
||
local domain="$1"
|
||
# basic domain validation (rfc 1035 compliant pattern)
|
||
# if [[ ! "$domain" =~ ^[a-za-z0-9]([a-za-z0-9-]{0,61}[a-za-z0-9])?(\.[a-za-z]{2,})+$ ]]; then
|
||
if [[ ! "$domain" =~ ^([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}$ ]]; then
|
||
echo "❌ error: invalid domain format: '${domain}'" >&2
|
||
echo " must be a valid domain (e.g., example.com)" >&2
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
if ! command -v docker &>/dev/null; then
|
||
echo "❌ error: docker not found. install docker first." >&2
|
||
exit 1
|
||
fi
|
||
|
||
# critical placeholder validation
|
||
if [[ "${main_domain}" == "example.com" || "${public_ip}" == "1.2.3.4" ]]; then
|
||
echo -e "\n⚠️ \033[1;31mcritical warning\033[0m: using default placeholder values!" >&2
|
||
echo " edit configuration section before proceeding." >&2
|
||
read -p "continue anyway? (y/n): " -r || exit 1
|
||
[[ ! "$reply" =~ ^[yy]$ ]] && echo "aborted." && exit 1
|
||
fi
|
||
|
||
# validate domains
|
||
validate_domain "${main_domain}"
|
||
validate_domain "${acme_dns_fqdn}"
|
||
|
||
# port conflict detection (precise regex to avoid false positives like port 530)
|
||
|
||
for port in "${dns_port}" "${api_port}"; do
|
||
# We check if the port IS found.
|
||
# awk returns 0 (success) if a match is found, triggering the warning.
|
||
if ss -tuln 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {found=1; exit} END {exit !found}'; then
|
||
echo "⚠️ Warning: Host port ${port} is in use!" >&2
|
||
echo " acme-dns requires exclusive access to these ports." >&2
|
||
read -p "Continue anyway? (y/n): " -n 1 -r
|
||
echo "" # move to a new line
|
||
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
|
||
fi
|
||
done
|
||
# ======================
|
||
# config generation (toml-safe)
|
||
# ======================
|
||
generate_config() {
|
||
local nsadmin="${admin_email//@/.}" # soa format: admin@domain.com → admin.domain.com
|
||
|
||
cat <<eof
|
||
[general]
|
||
listen = "0.0.0.0:${dns_port}"
|
||
protocol = "both"
|
||
domain = "${acme_dns_fqdn}"
|
||
nsname = "${acme_dns_fqdn}"
|
||
nsadmin = "${nsadmin}"
|
||
records = [
|
||
"${acme_dns_fqdn}. a ${public_ip}",
|
||
"${acme_dns_fqdn}. ns ${acme_dns_fqdn}."
|
||
]
|
||
debug = false
|
||
|
||
[database]
|
||
engine = "sqlite3"
|
||
connection = "/var/lib/acme-dns/acme-dns.db"
|
||
|
||
[api]
|
||
ip = "0.0.0.0"
|
||
port = "${api_port}"
|
||
disable_registration = false
|
||
tls = "${api_tls_mode}"
|
||
eof
|
||
|
||
# tls-specific configurations (toml-safe injection)
|
||
case "${api_tls_mode}" in
|
||
letsencrypt | letsencryptstaging)
|
||
cat <<eof
|
||
notification_email = "${le_notification_email}"
|
||
acme_cache_dir = "api-certs"
|
||
eof
|
||
;;
|
||
cert)
|
||
cat <<eof
|
||
tls_cert_privkey = "${custom_tls_cert_privkey}"
|
||
tls_cert_fullchain = "${custom_tls_cert_fullchain}"
|
||
eof
|
||
;;
|
||
esac
|
||
|
||
# security: hardcoded cors policy (avoids toml syntax errors from variable interpolation)
|
||
cat <<eof
|
||
corsorigins = [
|
||
"https://*.${main_domain}",
|
||
"https://${main_domain}"
|
||
]
|
||
use_header = false
|
||
header_name = "x-forwarded-for"
|
||
|
||
[logconfig]
|
||
loglevel = "${log_level}"
|
||
logtype = "stdout"
|
||
logformat = "text"
|
||
eof
|
||
}
|
||
|
||
generate_docker_compose() {
|
||
cat <<eof
|
||
version: '3.8'
|
||
services:
|
||
acme-dns:
|
||
image: joohoi/acme-dns:latest
|
||
container_name: acme-dns
|
||
restart: unless-stopped
|
||
ports:
|
||
- "${dns_port}:${dns_port}/udp"
|
||
- "${dns_port}:${dns_port}/tcp"
|
||
- "${api_port}:${api_port}/tcp"
|
||
volumes:
|
||
- ./config:/etc/acme-dns:ro
|
||
- ./data:/var/lib/acme-dns
|
||
- ./api-certs:/api-certs
|
||
# security hardening (critical for public dns service)
|
||
cap_drop:
|
||
- all
|
||
cap_add:
|
||
- net_bind_service # required for ports <1024
|
||
security_opt:
|
||
- no-new-privileges:true
|
||
# prevent privilege escalation
|
||
read_only: true
|
||
tmpfs:
|
||
- /tmp
|
||
eof
|
||
}
|
||
|
||
# ======================
|
||
# execution
|
||
# ======================
|
||
|
||
${COLOR_CAT} <<EOF
|
||
|
||
🔧 acme-dns setup generator (production hardened)
|
||
setup directory : ${setup_dir}
|
||
acme-dns domain : ${acme_dns_fqdn}
|
||
public ip : ${public_ip}
|
||
admin contact : ${admin_email} (soa record)
|
||
api mode : ${api_tls_mode} @ port ${api_port}
|
||
⚠️ cors restricted to: *.${main_domain}, ${main_domain}
|
||
|
||
EOF
|
||
|
||
# final confirmation with path safety check
|
||
if [[ "${setup_dir}" =~ ^/(bin|etc|usr|var/lib|root) ]]; then
|
||
echo "❌ error: unsafe setup_dir path detected. aborting." >&2
|
||
exit 1
|
||
fi
|
||
|
||
read -p "⚠️ this will create/overwrite ${setup_dir}. confirm? (y/n): " -r || exit 1
|
||
[[ ! "$REPLY" =~ ^[yy]$ ]] && echo "aborted by user." && exit 0
|
||
|
||
# Check that docker is running
|
||
if ! docker info >/dev/null 2>&1; then
|
||
echo "Error: Docker is not running. Aborting script."
|
||
exit 1
|
||
fi
|
||
|
||
# atomic directory setup
|
||
rm -rf "${setup_dir}" 2>/dev/null || true
|
||
mkdir -p "${setup_dir}"/{api-certs,config,data}
|
||
chown -R 1000:1000 ./data && chmod 755 ./data
|
||
chown -R 1000:1000 ./api-certs && chmod 755 ./api-certs
|
||
chmod 644 ./config/config.cfg
|
||
|
||
docker pull joohoi/acme-dns:latest >/dev/null 2>&1
|
||
|
||
generate_config >"${setup_dir}/config/config.cfg"
|
||
generate_docker_compose >"${setup_dir}/docker-compose.yml"
|
||
|
||
# ======================
|
||
# actionable deployment guide
|
||
# ======================
|
||
${COLOR_CAT} <<eof
|
||
|
||
✅ configuration generated in ${setup_dir}
|
||
|
||
❗❗ critical deployment steps (non-negotiable) ❗❗
|
||
|
||
1. 🌐 dns configuration (at registrar for ${main_domain}):
|
||
| type | host | value | ttl |
|
||
|------|----------------------|-----------------------|------|
|
||
| a | ${acme_dns_fqdn} | ${public_ip} | auto |
|
||
| ns | ${acme_dns_fqdn} | ${acme_dns_fqdn}. | auto |
|
||
|
||
🔑 must-haves:
|
||
- ns record value must end with trailing dot (.)
|
||
- verify propagation: dig +short ns ${acme_dns_fqdn}
|
||
- wait for global propagation (use https://dnschecker.org)
|
||
|
||
2. 🔒 pre-deploy security checklist:
|
||
[ ] edit config/config.cfg:
|
||
• after initial client registrations: set disable_registration=true
|
||
• for production: change api_tls_mode to "letsencrypt" (staging avoids rate limits)
|
||
• review cors origins (currently restricted to your domain)
|
||
[ ] if using "cert" mode: place certs in host's ./config/certs/ and verify paths
|
||
[ ] backup strategy: ./data/acme-dns.db contains critical registration tokens
|
||
[ ] firewall: allow udp/tcp 53 + tcp ${api_port} (sudo ufw allow 53; sudo ufw allow ${api_port}/tcp)
|
||
|
||
3. 🚀 deploy:
|
||
cd "${setup_dir}"
|
||
docker compose up -d # modern docker (v20.10+)
|
||
# legacy systems: docker-compose up -d
|
||
|
||
4. ✅ validate:
|
||
docker logs -f acme-dns # confirm "serving dns on :53" and "api listening on :${api_port}"
|
||
# for staging mode (self-signed):
|
||
curl -k https://localhost:${api_port}/health
|
||
# for production mode (valid cert):
|
||
curl https://localhost:${api_port}/health
|
||
|
||
💡 troubleshooting:
|
||
- 🛠️ check for dns services: use sudo netstat -tulnp | grep :53 to see what’s using port 53
|
||
- port 53 conflict? → sudo systemctl stop systemd-resolved; sudo systemctl disable systemd-resolved
|
||
- "permission denied" on ports? → ensure net_bind_service capability is present (in docker-compose.yml)
|
||
- dns queries failing? → check firewall allows udp 53; verify ns record has trailing dot
|
||
- api returns 403? → check cors origins match your client's origin exactly
|
||
|
||
📌 production essentials:
|
||
• use "letsencryptstaging" for all testing (avoids le rate limits)
|
||
• never expose disable_registration=false permanently
|
||
• monitor ./data/acme-dns.db backups (contains token secrets)
|
||
• this service is part of your pki infrastructure - treat with highest security priority
|
||
• full docs: https://github.com/joohoi/acme-dns/blob/master/readme.md
|
||
eof
|