No description
Find a file
Georg Franke 09812209af Call acme.sh via its absolute path, not the bare command
get.acme.sh's installer only adds a shell alias in ~/.bashrc (sourcing
~/.acme.sh/acme.sh.env) - it never puts a real binary on $PATH. Aliases
aren't expanded in non-interactive bash, and Debian's default ~/.bashrc
returns immediately when $PS1 is unset anyway, so the script's own
"source ~/.bashrc" never did anything. Confirmed live on ns01: `acme.sh
--version` worked interactively, `which acme.sh` returned nothing. Every
functional acme.sh call in the script (the installed-check and both
--issue invocations) now uses $ACME_SH (/root/.acme.sh/acme.sh) directly
- previously these were almost certainly silently failing with "command
not found", which the script's own error handling misattributed to DNS
not being ready yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 18:26:10 +02:00
full_dns_deployment.sh Call acme.sh via its absolute path, not the bare command 2026-07-26 18:26:10 +02:00
README.md Call acme.sh via its absolute path, not the bare command 2026-07-26 18:26:10 +02:00

dns-recursor

A single bash script that deploys a hardened, self-hosted recursive DNS resolver with DNSSEC validation, RPZ-based ad/malware blocking, monitoring, and optional encrypted DNS (DoT/DoH) on Debian 13 (Trixie) — as a one-shot run, including TLS certificate issuance.

What it deploys

full_dns_deployment.sh provisions a complete DNS resolver stack in one run:

Component Role Port
PowerDNS Recursor Recursive resolution, DNSSEC validation, RPZ blocklist enforcement 127.0.0.1:5300 (internal)
dnsdist Public-facing frontend: packet caching, per-IP rate limiting 0.0.0.0:53
Nginx TLS termination for the block page and (optionally) DoH 80/443
acme.sh Issues + auto-renews RSA and ECC certificates (Let's Encrypt, webroot)
Prometheus Metrics collection (self, node_exporter, dnsdist, PowerDNS) 9090
Grafana Metrics dashboards 3000
node_exporter Host metrics 9100
stunnel4 (optional) DNS-over-TLS 853
cloudflared (optional) DNS-over-HTTPS, proxied through Nginx loopback only

Architecture

client → dnsdist (:53, cache + rate limit) → PowerDNS Recursor (:5300, DNSSEC + RPZ)
                                                    │
                                     blocked domain │ resolves normally
                                                    ▼
                                     A record → BLOCK_IP → Nginx block page

dnsdist is a pure port-53 frontend — all recursion, DNSSEC validation, and RPZ blocking happen in PowerDNS Recursor. This split exists because dnsdist has no RPZAction().

Upstream forwarding over DNS-over-TLS

forward_zones_recurse sends all . queries to Quad9 (9.9.9.9:853) and Cloudflare (1.1.1.1:853) over DNS-over-TLS rather than plain UDP/TCP port 53 (outgoing.dot_to_port_853: true, PowerDNS Recursor's built-in behavior for forwarders specified on port 853). This isn't just a privacy nicety — it was required to get resolution working at all on the reference host (ns01): outbound UDP/53 to external resolvers reliably timed out there despite the firewall showing UDP as allowed (most likely provider-side anti-abuse filtering of that exact traffic pattern, invisible in the firewall UI), while TCP/53 and HTTPS worked fine. DoT is always TCP-based, so it sidesteps that class of problem entirely, on top of encrypting the resolver's own upstream queries.

PowerDNS metrics (native, no separate exporter)

Prometheus scrapes PowerDNS Recursor's own built-in /metrics endpoint (webservice: section, 127.0.0.1:8081 by default, basic-auth password in /root/.powerdns-credentials) rather than a separate exporter binary. prometheus-community/powerdns_exporter — which an earlier version of this script tried to download — no longer exists as a GitHub repo at all (confirmed 404 on the whole repo, not just the release asset); the unguarded wget for it used to kill the script silently under set -e with zero error output. The recursor has shipped its own /metrics endpoint since 4.3.0, so no external exporter is needed.

Blocklisting (RPZ)

Blocklists are pulled from Firebog (falling back to the StevenBlack hosts list), ThreatFox, and HaGeZi Multi PRO + Threat Intelligence Feeds (TIF), then written as a proper RPZ zone file (/etc/powerdns/blocklist.rpz) with local A-records pointing each blocked domain (and its wildcard) at BLOCK_IP. This is intentionally not a hosts-file dump — real RPZ zone syntax is required so the recursor can respond authoritatively and redirect the client to the block page. The list is refreshed daily via cron (/usr/local/sbin/update-blocklist.sh).

HaGeZi also publishes its lists as ready-to-use RPZ zone files directly (rpz/pro.txt, rpz/tif.txt), but those encode the block action as CNAME . (NXDOMAIN) rather than a redirect to a block page, so this script deliberately keeps using the plain domain lists and folds them into its own A → BLOCK_IP zone instead of consuming HaGeZi's RPZ files as-is.

One hostname for everything

BLOCK_DOMAIN is set to $HOSTNAME (ns01.sb.services) — the block page and DoH endpoint live on the resolver's own hostname instead of a separate domain. That means only one certificate pair is needed, and DoH can be wired up as an Nginx location on the same vhost instead of needing its own port or vhost.

Block page

The generic block page is served from /var/www/ns01.sb.services/public — Nginx's root is scoped to that leaf public/ subfolder rather than the domain directory itself, so anything else that ends up in /var/www/ns01.sb.services/ later (backups, logs) is never accidentally exposed over HTTP. The page is domain-agnostic: it renders a generic "blocked" message and fills in the specific domain client-side from the ?domain= query string that the RPZ redirect (see Architecture above) appends.

TLS certificates (issued by this script, RSA + ECC)

The script issues both an RSA (4096-bit) and an ECC certificate for $BLOCK_DOMAIN via acme.sh's webroot method, as part of the same run:

acme.sh --issue -d "$BLOCK_DOMAIN" -w /var/www/letsencrypt/ \
    --key-file /etc/ssl/$BLOCK_DOMAIN/ecc_key.pem --fullchain-file /etc/ssl/$BLOCK_DOMAIN/ecc_fullchain.pem \
    --server letsencrypt --ecc --reloadcmd 'systemctl restart nginx'

acme.sh --issue -d "$BLOCK_DOMAIN" -w /var/www/letsencrypt/ \
    --key-file /etc/ssl/$BLOCK_DOMAIN/key.pem --fullchain-file /etc/ssl/$BLOCK_DOMAIN/fullchain.pem \
    --server letsencrypt -k 4096 --reloadcmd 'systemctl restart nginx'

Sequence within the script (section 6):

  1. Installs acme.sh if missing (checked via the actual binary at /root/.acme.sh/acme.sh, not command -v — see note below).
  2. Creates /etc/ssl/$BLOCK_DOMAIN/ and the /var/www/letsencrypt/.well-known/acme-challenge/ webroot (owned by www-data).
  3. Generates self-signed placeholders (RSA 4096 + ECC prime256v1) at those same paths — purely so Nginx has something valid to bind to.
  4. Writes the Nginx vhost (with the /.well-known/acme-challenge/ location) and starts Nginx.
  5. Only now — with Nginx actually serving the webroot — runs the two acme.sh --issue calls above. On success they overwrite the placeholders in place and register --reloadcmd 'systemctl restart nginx' for future renewals.
  6. If issuance fails (e.g. DNS doesn't point at the server yet), the script prints a warning with the exact command to re-run later and continues with the self-signed placeholder — it does not abort the deployment.

Why the absolute path matters: get.acme.sh's installer only adds a shell alias in ~/.bashrc (sourcing ~/.acme.sh/acme.sh.env) — it never puts a real binary on $PATH. Aliases aren't expanded in non-interactive bash (which is exactly how this script runs), and Debian's default ~/.bashrc returns immediately when $PS1 is unset anyway, so source ~/.bashrc inside the script does nothing. Confirmed live on ns01: acme.sh --version worked in an interactive shell, which acme.sh returned nothing. The script therefore calls the real binary directly (/root/.acme.sh/acme.sh, i.e. $ACME_SH) everywhere it actually needs to run acme.sh — the printed manual-retry commands still say plain acme.sh since those are meant to be pasted into your interactive terminal, where the alias does work.

Renewal afterwards needs no cron entry of its own: get.acme.sh's installer already sets up its own daily cron job, which reuses whatever --key-file/--fullchain-file/--reloadcmd was registered at issuance time.

Nginx picks between the two certs automatically per-client (ECC preferred, RSA fallback) via a dual ssl_certificate/ssl_certificate_key pair in the same server block — standard behavior since nginx 1.11.0.

DoH via Nginx reverse proxy (not a separate public port)

cloudflared binds only to 127.0.0.1:$DOH_INTERNAL_PORT (default 8443) — it never touches a public interface. Nginx terminates TLS on 443 (same vhost/cert as the block page) and reverse-proxies /dns-query to it via an included snippet (/etc/nginx/snippets/doh-location.conf, populated only if DoH is enabled). This avoids a port-443 clash with the block page's own Nginx vhost, and means DoT + DoH + the block page can all run simultaneously on the same public IP. DoH ends up reachable at https://ns01.sb.services/dns-query.

Requirements

  • Debian 13 (Trixie), run as root
  • $BLOCK_DOMAIN (i.e. $HOSTNAME) must already resolve to this server's public IP before running the script, or the acme.sh webroot issuance step will fail (non-fatal — falls back to self-signed, see above)
  • Outbound internet access (package repos, GitHub releases, blocklist feeds, Let's Encrypt)

Usage

sudo ./full_dns_deployment.sh

The script is interactive at two points: whether to enable DNS-over-TLS (stunnel4) and whether to enable DNS-over-HTTPS (cloudflared + Nginx reverse proxy).

It is idempotent-ish (uses systemctl restart/enable, overwrites its own config files) but not designed to be re-run blindly against a differently-configured host — review the variables below first.

Configuration

Edit the variables at the top of the script before running:

Variable Default Purpose
HOSTNAME ns01.sb.services Resolver hostname
BLOCK_DOMAIN $HOSTNAME Domain serving the block page + DoH (deliberately the same as HOSTNAME, see above)
BLOCK_IP 192.0.2.1 Target IP for blocked domains (TEST-NET-1 / RFC 5737 placeholder — replace with a real routable IP for production)
EMAIL admin@sb.services acme.sh account registration email
CERT_DIR / RSA_CERT / RSA_KEY / ECC_CERT / ECC_KEY /etc/ssl/$BLOCK_DOMAIN/{fullchain,key,ecc_fullchain,ecc_key}.pem Where acme.sh writes certs, and what Nginx/stunnel4 read from
ACME_WEBROOT /var/www/letsencrypt Webroot acme.sh validates HTTP-01 challenges against
GRAFANA_PORT 3000
PROMETHEUS_PORT 9090
DNSDIST_STATS_PORT 8083 dnsdist stats/metrics API (localhost-only ACL)
DNSDIST_CTRL_PORT 8084 dnsdist Lua console
DOT_PORT 853
DOH_INTERNAL_PORT 8443 cloudflared's loopback-only bind; never opened in the firewall
PDNS_WEBSERVICE_PORT 8081 PowerDNS Recursor's built-in /metrics endpoint (localhost-only)

PDNS_USER (pdns) and DNSDIST_USER (_dnsdist) are the Debian package system-user names — verified via getent passwd, not guessed.

Cron jobs

The script installs exactly two entries in root's crontab:

0 3 * * * /usr/local/sbin/update-blocklist.sh >> /var/log/blocklist-update.log 2>&1
0 4 * * * find /var/log/nginx -name "*.log" -size +10M -exec truncate -s 0 {} \;

Certificate renewal is not added as a separate crontab entry — get.acme.sh's installer already sets up its own daily job, and it reuses the --key-file/--fullchain-file/--reloadcmd settings registered when the certs were issued (see TLS certificates above). Adding another entry here would just create duplicate/triplicate renewal runs.

Post-deployment checklist

The script prints a summary with test commands at the end, plus a list of items it deliberately leaves unverified:

  • TLS certificates: if the acme.sh issuance step printed a warning, $CERT_DIR still holds self-signed placeholders — most likely because $BLOCK_DOMAIN didn't resolve to this server yet at run time. Re-run the printed acme.sh --issue ... commands once DNS is correct.
  • BLOCK_IP is only bound to lo temporarily; add it persistently in /etc/network/interfaces or via systemd-networkd for it to survive a reboot.
  • RPZ blocking test: with millions of combined blocklist entries (Firebog + ThreatFox + full HaGeZi Pro + full HaGeZi TIF), the recursor can take real time to load/reload the zone. The script polls for up to 60s before giving up — if it still warns, just retest manually a bit later (dig @127.0.0.1 -p 5300 doubleclick.net) before assuming something's actually broken.
  • dnsdist stats API ACL is restricted to 127.0.0.1, but UFW still opens DNSDIST_STATS_PORT externally — tighten this if you don't intend to expose it.
  • Grafana ships with the default admin/admin login — change it on first login.

Quick verification

# Recursor (internal, direct)
dig @127.0.0.1 -p 5300 google.com
dig @127.0.0.1 -p 5300 dnssec-failed.org +dnssec   # must return SERVFAIL

# Public resolver via dnsdist
dig @<server-ip> google.com
dig @<server-ip> doubleclick.net                   # should return BLOCK_IP

# Block page
curl -v https://ns01.sb.services

# DoH (if enabled)
curl -H 'accept: application/dns-json' 'https://ns01.sb.services/dns-query?name=example.com&type=A'

# dnsdist stats (local only)
curl http://127.0.0.1:8083/jsonstat?command=stats | jq

Logs

Service Command
PowerDNS Recursor journalctl -u pdns-recursor -f
dnsdist journalctl -u dnsdist -f
Nginx tail -f /var/log/nginx/*.log
cloudflared (DoH) journalctl -u cloudflared -f
Blocklist updates tail -f /var/log/blocklist-update.log
acme.sh ~/.acme.sh/acme.sh.log

Notes

  • Comments and console output in the script are in German; this README summarizes the same content in English.
  • The script targets a specific verified environment (host ns01) — package/user names (pdns, _dnsdist) were confirmed on that box rather than assumed, per the changelog at the top of the script.
  • cloudflared's exact CLI flags for DoH proxying (--doh-addr/--doh-upstream) are inherited from an earlier version of this script and have not been independently verified against current cloudflared releases — if DoH doesn't come up, check cloudflared proxy-dns --help on the target host first.