A reusable bash installer that turns a fresh Debian 13 (trixie) host into a fully-functional OpenNIC Tier-2 DNS resolver speaking Do53, DoT, DoH, and DNSCrypt, with DNSSEC validation against both IANA and OpenNIC root keys, and a strict no-logs posture throughout.
┌──────────────────────────────────────┐
Public users │ Public IPv4/IPv6 │
──────────────► │ │
:53 Do53 UDP/TCP │ ┌───────────┐ │
:853 DoT │ │ dnsdist │ ──► 127.0.0.1:5353 │
:8443 DNSCrypt │ └───────────┘ (BIND9) │
:443 DoH+HTTPS │ │
│ ┌───────────┐ │
│ │ nginx │ ──► 127.0.0.1:5443 │
│ │ TLS term │ (dnsdist DoH) │
│ └───────────┘ │
│ │ │
│ └──► /var/www/<host>/ │
└──────────────────────────────────────┘
A typical fresh-host install completes in under a minute and is fully idempotent — re-run install.sh any time, every step is skip-safe.
- BIND9 as the recursive resolver. ICANN names recurse normally; OpenNIC TLDs and the OpenNIC root are slaved from official Tier-1 nameservers (the OpenNIC-recommended Tier-2 method). DNSSEC validation against IANA's auto-managed root anchor and the live OpenNIC root KSK.
- dnsdist terminating Do53, DoT, and DNSCrypt directly on the public IP. Per-IP rate limiting, ANY-query refusal, non-recursive query refusal.
- nginx owning :443 — TLS-terminates the operator info page on
/and reverse-proxies DoH on/dns-queryto dnsdist on127.0.0.1:5443.trustForwardedForHeader=truekeeps per-IP rate limits accurate. - Let's Encrypt certificate via certbot (HTTP-01 standalone for issuance, webroot for renewals; DNS-01 with Cloudflare also supported). Renewal deploy hook re-deploys cert material to nginx and dnsdist automatically.
- unattended-upgrades for security patches with a configurable reboot policy.
- A static, JS-free operator info page that respects the visitor's
prefers-color-scheme, lists endpoints + DNSCrypt/DoH stamps, and surfaces the operator's abuse / security contacts.
No logs anywhere. BIND query logging is off, dnsdist is verbose-off and never invokes a logging action, nginx has access_log off on every server. Operational events go to syslog only and contain no client data.
- A Debian 13 (trixie) host. LXC, VM, or bare metal all work. (LXC: ensure the host kernel has
nf_conntrack_max=524288andnet.core.{r,w}mem_max=4194304if you expect public-internet load.) - A publicly-routable IPv4 address (and optionally IPv6) reachable on:
:53/udp+tcp— Do53:80/tcp— Let's Encrypt HTTP-01 challenge (initial issuance and renewals when using the defaulthttp-01mode):443/tcp— nginx (HTTPS info page + DoH):853/tcp— DoT:8443/udp+tcp— DNSCrypt
- An A record (and optionally AAAA) pointing the resolver hostname at the host before you run the installer. Certbot will refuse to issue otherwise.
- Root on the host (
sudo bash install.sh).
If port 80 is blocked at your provider's edge or you need wildcard certs, switch ACME_CHALLENGE to dns-01 (Cloudflare is the bundled provider; see Adding DNS providers).
git clone https://github.com/SorcerousMachine/opennic-tier2-installer.git
cd opennic-tier2-installer
cp install.conf.example install.conf
$EDITOR install.conf # set RESOLVER_HOSTNAME, RESOLVER_IPV4, etc.
sudo bash install.shThat's it. The installer prints the DNSCrypt and DoH server stamps at the end. Back up /etc/dnsdist/dnscrypt/provider.key immediately — losing it forces a provider-name rotation, which invalidates every stamp anyone has stored.
While iterating on a new host, set LE_STAGING="true" in install.conf to use Let's Encrypt's staging endpoint. Switch it back to "false" and re-run install.sh once you're ready to go live; the installer detects the staging cert and re-issues against production.
-
Back up the DNSCrypt provider key to somewhere off-host:
sudo cp /etc/dnsdist/dnscrypt/provider.key /secure-offsite-location/
This is the catastrophic-loss file. The installer reminds you about it loudly.
-
Apply for an OpenNIC Tier-2 listing: https://wiki.opennic.org/opennic/tier2. The OpenNIC team will check your resolver and add it to the public list.
-
Submit your DNSCrypt + DoH stamps to the public DNSCrypt registry: https://github.com/DNSCrypt/dnscrypt-resolvers.
-
Subscribe to the OpenNIC mailing list (or watch the wiki) so you're notified about trust-anchor rollovers and infrastructure changes.
If you ever need to print the stamps again, run:
sudo bash scripts/print-stamps.shAll operator-tunable values live in install.conf (gitignored). Copy install.conf.example and edit. The installer refuses to run if any required value is missing or still equals its placeholder.
| Variable | Description |
|---|---|
RESOLVER_HOSTNAME |
Public hostname, e.g. dns.example.org. Must already have an A (and optionally AAAA) record. |
RESOLVER_IPV4 |
Public IPv4 address the world sees you on. |
CERTBOT_EMAIL |
Used by Let's Encrypt for expiry notifications. |
OPERATOR_NAME |
Shown on the info page. |
OPERATOR_ABUSE_EMAIL |
Required for the OpenNIC Tier-2 listing. Must be a real, monitored mailbox. |
OPERATOR_SECURITY_EMAIL |
Required for responsible disclosure. |
| Variable | Default | Description |
|---|---|---|
RESOLVER_IPV6 |
(empty) | Public IPv6. Empty disables v6 listeners. |
ACME_CHALLENGE |
http-01 |
http-01 (port 80 reachable) or dns-01. |
DNS_PROVIDER |
cloudflare |
DNS-01 provider. cloudflare ships; others need a small patch (see below). |
DNS_PROVIDER_API_TOKEN |
(empty) | Cloudflare token scoped Zone:DNS:Edit on the parent zone. |
LE_STAGING |
false |
true uses LE staging (untrusted cert; safe to retry). |
DNSCRYPT_PROVIDER_NAME |
2.dnscrypt-cert.<RESOLVER_HOSTNAME> |
Pick once and don't change — every published stamp embeds it. |
OPERATOR_REGION |
(empty) | Free-text region/datacenter for the info page. |
OPERATOR_HOMEPAGE_URL |
https://<RESOLVER_HOSTNAME>/ |
Linked from the info page footer. |
AUTO_REBOOT_TIME |
now |
Reboot policy for unattended-upgrades. now, HH:MM, or empty (disable). |
SLAVE_OPENNIC_ROOT |
true |
Slave the OpenNIC root in addition to the 16 TLDs. Required for OpenNIC listing. See below. |
The default (true) is the canonical OpenNIC Tier-2 layout from the OpenNIC wiki: the OpenNIC root plus the 16 TLDs are all slaved from official Tier-1 nameservers. The resolver answers . SOA / . NS / . DNSKEY authoritatively with OpenNIC root data, which is what OpenNIC's hourly health checker probes for. If you intend to list the resolver on the public OpenNIC servers page, you must keep this on, otherwise the member portal will mark you Offline and eventually auto-delist.
Setting false switches to IANA root hints: ICANN names recurse through the IANA hierarchy and produce AD-flagged DNSSEC responses end-to-end including NXDOMAIN; OpenNIC TLDs are still served from local slaves; a small forward zone for glue. covers the OpenNIC infrastructure namespace. This mode is only appropriate for private deployments not listed with OpenNIC (internal use, lab setups). The trade-off the other way is that root-level NXDOMAIN coming back authoritatively under true (AA flag, no AD) breaks dnscrypt-proxy's require_dnssec self-check, so DNSCrypt clients can't enable strict DNSSEC against the resolver. That is the cost of being a real OpenNIC Tier-2; until that incompatibility is resolved upstream, OpenNIC listing wins.
AUTO_REBOOT_TIME=now reboots the host immediately when a kernel/glibc/openssl update lands that requires it. You're patched fast, but resolution drops for ~30–60 seconds while the host comes back. This is the right default for a public Tier-2: the alternative is to leave a privilege-escalation or RCE flaw exposed for arbitrary days.
If you have multiple resolvers behind a load balancer, set this to a quiet hour (AUTO_REBOOT_TIME=04:30) and stagger the times so they don't all reboot at once. If you handle reboots manually, set it to empty (AUTO_REBOOT_TIME=""). Either way, don't disable unattended-upgrades itself — the security patches still need to land.
All in scripts/. Each is a one-shot, idempotent operation.
| Script | Purpose |
|---|---|
verify.sh |
End-to-end smoke test (Do53, DoT, DoH, DNSCrypt, info page, IPv6 if configured). Exit 0 on full pass. |
print-stamps.sh |
Re-print DNSCrypt + DoH stamps in case the install summary scrolled away. |
refresh-opennic-config.sh |
Re-pull current OpenNIC Tier-1 IPs + TLD list, regenerate BIND config, reload BIND. Run after OpenNIC adds a TLD or rotates infrastructure. |
update-opennic-trust-anchor.sh |
Re-fetch the OpenNIC root KSK and update BIND's trust anchor. Run after OpenNIC announces a key rollover. |
uninstall.sh |
Clean removal. --packages also apt purges installed packages; --le-account deletes the LE account state. Always backs up provider.key to /root/ first. |
In scripts/integrations/. These are deliberately separate from the main installer — operators run them only if they want the corresponding capability. Each is purely additive: enabling one does not change behavior for clients of the existing transports.
Exposes this resolver as a hidden service inside the I2P anonymity network, reachable at a persistent <destination>.b32.i2p address. Same resolver, same posture (no logs, no filtering, DNSSEC-validating). Just a fifth transport alongside Do53/DoT/DoH/DNSCrypt for users who want their query traffic to traverse I2P rather than clearnet.
Architecture:
I2P client → i2pd server tunnel → 127.0.0.1:53 (dnsdist) → 127.0.0.1:5353 (BIND)
i2pd terminates the I2P tunnel locally and forwards both TCP and UDP DNS to dnsdist on loopback. Because every DoI client appears to dnsdist as 127.0.0.1, the per-IP rate limit explicitly exempts loopback — otherwise one busy I2P client would starve every other I2P client out of a single shared 50-qps bucket. Anti-abuse for I2P-side traffic is delegated to i2pd's own tunnel-level controls; clearnet sources still get the per-IP cap with their real source addresses.
Enable:
sudo bash scripts/integrations/enable-doi.shThe script installs i2pd, drops a tunnels config at /etc/i2pd/tunnels.conf.d/opennic-dns.conf, starts i2pd, and saves the generated .b32.i2p address to /var/lib/opennic-tier2-install/i2p-address.txt. First-time tunnel establishment takes 5-15 minutes after i2pd starts while it bootstraps into the I2P network — sudo journalctl -u i2pd -f shows progress.
Operational notes:
- Back up
/var/lib/i2pd/opennic-dns.dat— that file holds the persistent destination keypair. Same "lose-it-and-the-address-changes" property as the DNSCrypt provider key. Anyone with the old address would silently fail until they rediscover the new one. - Latency over I2P is significantly higher than clearnet (typically 300ms–2s of extra round-trip). I2P is not a low-latency transport. Clients that care about query speed should use one of the clearnet transports.
- What's not done by the script: publishing the
.b32.i2paddress. That's an editorial decision. Reasonable venues if you want discovery: your operator info page, the I2P forum's services subforum,r/i2p, the#i2pIRC channel on Libera Chat, and the description field of your OpenNIC Tier-2 listing. The script prints client-side tunnel configuration that operators of other i2pd installs can paste straight into theirtunnels.confto reach you. - What this script does NOT do: make
.i2pnames resolvable from a regular browser. That's the inverse direction (DNS resolver as I2P client rather than I2P-accessible service) and is out of scope by design — bridging.i2pcontent out to clearnet defeats the anonymity guarantees the I2P transport provides, and the I2P community generally pushes back on outproxy-style integrations that go the other way.
To unwind: sudo systemctl disable --now i2pd && sudo apt-get purge i2pd. The .dat keypair file is left in /var/lib/i2pd/ in case you change your mind; remove it manually if you want a clean slate.
Weekly — nothing. The installer runs unattended-upgrades on the systemd timer; certbot runs its own renewal timer. As long as both are healthy you're fine.
Monthly — sudo bash scripts/verify.sh. Catches anything that broke silently (provider blocked port 853, cert renewal failed because port 80 is blocked, etc.).
Quarterly — sudo bash scripts/refresh-opennic-config.sh. Refreshes the Tier-1 IP list and the TLD list. OpenNIC infrastructure rarely changes, but when it does, slaved zones can fall behind silently.
Annually — Watch for OpenNIC trust anchor rollover announcements. Run sudo bash scripts/update-opennic-trust-anchor.sh when one happens. RFC 5011 will manage routine rollovers automatically (the initial-key stanza), but if OpenNIC publishes a one-shot key change without a rollover overlap, the manual fetch is what gets you back to validating.
This is the bad case. The provider keypair is what signs short-term resolver certificates; clients pin the provider name + provider public key in their stamp. If the private key is gone, you cannot generate new resolver certs that match the published stamp.
The only recovery is:
- Pick a new
DNSCRYPT_PROVIDER_NAME(e.g., bump2.to3.). - Re-run
install.sh— new keypair generated. - Publish the new stamps.
- Notify users of the change. Old stamps will fail.
This is why the installer reminds you to back up /etc/dnsdist/dnscrypt/provider.key loudly. Do it on day one.
Check journalctl -u certbot and /var/log/letsencrypt/letsencrypt.log. The most common causes:
- Port 80 blocked: your provider added a firewall rule, or some other service grabbed
:80. With HTTP-01 webroot, nginx must continue to serve/.well-known/acme-challenge/. Checknginx -T | grep acme. - DNS A record changed: certbot validates against your hostname's current A record. If you moved hosts and forgot to update DNS, validation fails.
- Account locked: hitting LE rate limits during testing. Switch to
LE_STAGING=trueand try again, or wait.
To force a manual renewal: sudo certbot renew --force-renewal.
sudo journalctl -u named --since "1 hour ago" | grep -E '(transfer|DNSSEC|managed-keys)'Look for AXFR failures (Tier-1 server unreachable) or DNSSEC validation failures (trust anchor stale). Run sudo bash scripts/refresh-opennic-config.sh to re-pull, or sudo bash scripts/update-opennic-trust-anchor.sh if it's the trust anchor.
sudo bash scripts/verify.sh # tests against the public IP from the host itselfIf the local-host-as-public-client tests pass but external clients still time out, it's network-layer: provider firewall, upstream router ACLs, ISP blocking inbound :53, etc. None of these are fixable from inside the resolver — talk to your provider.
dns-01 ships only Cloudflare. Adding a new provider takes ~10 lines:
- Update
install.conf.exampleto document the new provider name. - In
lib/helpers.sh::validate_config, add the provider to thecase "$DNS_PROVIDER"allowlist. - In
lib/steps_certbot.sh::step_certbot_install_package, add the apt package (e.g.,python3-certbot-dns-route53for Route 53). - In
lib/steps_certbot.sh::step_certbot_install_dns_credentials, add a branch that writes the provider's credentials file to/etc/letsencrypt/<provider>.ini. - In
lib/steps_certbot.sh::step_certbot_issue, add acaseentry that passes the right--dns-<provider>flags to certbot.
PRs welcome.
Where to report security issues: the operator running the resolver should set this in their install.conf's OPERATOR_SECURITY_EMAIL, and it shows on the public info page. For issues in the installer itself, file a GitHub issue on this repo (or, if it's sensitive, the contact in the upstream's security policy).
The installer never writes secrets to its own log (/var/log/opennic-tier2-install.log). Cloudflare API tokens, dnsdist console keys, the DNSCrypt provider private key — none touch the log. If you find a path where one does, that's a bug; please report it.
Built with substantial AI assistance under human direction. See Sorcerous Machine — Practices for the full disclosure on AI usage and commit attribution.
MIT.