- Shell 100%
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> |
||
|---|---|---|
| full_dns_deployment.sh | ||
| README.md | ||
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):
- Installs
acme.shif missing (checked via the actual binary at/root/.acme.sh/acme.sh, notcommand -v— see note below). - Creates
/etc/ssl/$BLOCK_DOMAIN/and the/var/www/letsencrypt/.well-known/acme-challenge/webroot (owned bywww-data). - Generates self-signed placeholders (RSA 4096 + ECC prime256v1) at those same paths — purely so Nginx has something valid to bind to.
- Writes the Nginx vhost (with the
/.well-known/acme-challenge/location) and starts Nginx. - Only now — with Nginx actually serving the webroot — runs the two
acme.sh --issuecalls above. On success they overwrite the placeholders in place and register--reloadcmd 'systemctl restart nginx'for future renewals. - 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~/.bashrcreturns immediately when$PS1is unset anyway, sosource ~/.bashrcinside the script does nothing. Confirmed live onns01:acme.sh --versionworked in an interactive shell,which acme.shreturned 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 plainacme.shsince 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_DIRstill holds self-signed placeholders — most likely because$BLOCK_DOMAINdidn't resolve to this server yet at run time. Re-run the printedacme.sh --issue ...commands once DNS is correct. BLOCK_IPis only bound tolotemporarily; add it persistently in/etc/network/interfacesor 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 opensDNSDIST_STATS_PORTexternally — tighten this if you don't intend to expose it. - Grafana ships with the default
admin/adminlogin — 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 currentcloudflaredreleases — if DoH doesn't come up, checkcloudflared proxy-dns --helpon the target host first.