Skip to content
LegitCamperPublic

About

An ad blocking dns server in rust

Resources

Stars

1 star

Watchers

0 watching

Forks

Repository files navigation

dns-rs

Self-hosted, ad-blocking DNS resolver that only speaks encrypted DNS — DNS-over-TLS (DoT) and DNS-over-HTTPS (DoH).

Point your router or devices at it instead of your ISP's resolver. Ads and trackers get dropped before they resolve, and the queries that do go upstream are encrypted end to end.

Written in Rust: a single stripped binary, no runtime, no GC, no interpreter. It idles at a few MB of RAM and answers cache hits from cached wire bytes, so it's happy on a Raspberry Pi, an old NUC, or a free serverless tier.

What you get

  • DoT on 853 and DoH on 443, both TLS-terminated by the resolver itself. Nothing listens on plaintext port 53.
  • Blocklists fetched over HTTPS on a schedule you set. Hosts-file format, plain domain-per-line, and Adblock Plus network rules (||domain^) all work — point it at StevenBlack/hosts and you're done.
  • Whitelist of exact domains that override every blocklist, re-applied on every refresh so a background update can't re-block something you carved out.
  • Static hosts — nas.home = 192.168.1.10 style overrides, answered locally, never forwarded upstream.
  • Block mode: NXDOMAIN, or sinkhole to an IP you pick (Pi-hole style).
  • Fast cache with a byte budget instead of an entry count, so tiny NXDOMAIN answers and fat TXT records each use only what they need. Failures (NXDOMAIN / NODATA) get cached too, when upstream says how long that's safe.
  • Multiple upstreams: try them in order, hedge slow requests after 25 ms, or race all of them. Duplicate in-flight queries collapse into one request.
  • Hot config reload — edit config.toml and it picks up the change without restarting or dropping connections.

Quick start (Docker)

docker run -d \
  --name dns-rs \
  -p 853:853 \
  -p 443:443 \
  -v /path/to/config.toml:/etc/dns-rs/config.toml:ro \
  -v /path/to/fullchain.pem:/etc/dns-rs/fullchain.pem:ro \
  -v /path/to/privkey.pem:/etc/dns-rs/privkey.pem:ro \
  ghcr.io/legitcamper/dns-rs:latest

Start from config.example.toml — every option is commented. Shape of it:

[server]        # bind address, DoT/DoH ports, TLS cert/key paths
[blocking]      # nxdomain or sinkhole, and the sinkhole IP
[blocklists]    # URLs to fetch, refresh interval
[whitelist]     # domains blocklists may never block
[upstream]      # DoT/DoH upstream resolvers, sequential or race
[static_hosts]  # your own domain -> IP overrides
[cache]         # enabled + total byte budget

The image runs as an unprivileged user with cap_net_bind_service on the binary, so binding 853/443 needs no --privileged and no -u root. Your config's tls_cert/tls_key should point at the paths you mounted inside the container. Tags: :latest, :<git-sha>, :vX.Y.Z.

You need a TLS certificate: self-signed is fine if you can trust it on your clients, or use a DNS-validated ACME cert if the resolver has a public name.

From source

cargo build --release
./target/release/dns-rs --config /path/to/config.toml

Binding 853/443 without root needs cap_net_bind_service on the binary, or just use high ports in config.toml.

Platform TLS (Fly, Cloud Run, Azure, etc.)

The server can expose plaintext internal listeners for platforms that terminate TLS at the edge. serverless selects environment-variable configuration; certless selects plaintext inbound transports; dot and doh independently select protocols. Outbound queries remain encrypted.

The published serverless image enables all four features and listens on 0.0.0.0:8853 for DNS-over-TCP and 0.0.0.0:${PORT:-8053} for HTTP DoH:

docker run --rm -p 8853:8853 -p 8053:8053 \
  ghcr.io/legitcamper/dns-rs-serverless:latest

Do not expose these listeners directly to an untrusted network. They are only safe behind a TLS-terminating proxy.

Fly.io

fly.toml maps public DoH 443 and DoT 853 to those internal listeners, uses Fly-managed certificates, and keeps one Machine running to avoid DNS cold starts:

fly launch --no-deploy
fly certs add dns.example.com
fly deploy

Set your Fly app name and primary region during fly launch; the checked-in config intentionally omits account-specific values. Clients use https://dns.example.com/dns-query for DoH and dns.example.com:853 for DoT.

Health checks and recovery

GET /healthz checks HTTP liveness. GET /readyz sends a root NS query through the configured upstream strategy, bypassing local cache and blocklists. It returns HTTP 200 for a valid root NS answer, or 503 for DNS errors, malformed answers, or upstream failure (bounded by the normal five-second query timeout). Both endpoints use the DoH listener: HTTPS in the default build, HTTP behind platform TLS in the certless build. DoT-only builds have no HTTP endpoints.

Fly checks /readyz every 15 seconds with a six-second timeout and a 30-second startup grace period. Failed checks remove the Machine from DoH routing; passing checks restore it. DoT retains a TCP check on port 8853, which detects listener reachability only. Fly service HTTP checks target their service's internal port, so the DoT service cannot reuse port 8053's readiness endpoint. Existing client connections are not migrated by routing checks.

Run at least two Machines (fly scale count 2) to have a healthy failover target. Inspect checks with fly checks list. Fly health checks do not restart Machines; the configured on-failure policy retries process crashes up to 10 times. For a running but stuck Machine, use fly machine restart <machine-id>. Automatic restarts on failed checks require an external watchdog; upstream outages alone should not trigger restart loops. See Fly health checks.

Environment variables
Variable Default
PORT / DNSRS_DOH_PORT 8053 (PORT wins)
DNSRS_DOT_PORT 8853
DNSRS_BIND_ADDRESS 0.0.0.0
DNSRS_TLS_CERT, DNSRS_TLS_KEY required only without certless
DNSRS_UPSTREAM_URLS https://cloudflare-dns.com/dns-query
DNSRS_UPSTREAM_STRATEGY hedged (sequential, hedged, or race)
DNSRS_BLOCKLIST_URLS, DNSRS_BLOCKLIST_DOMAINS empty comma-separated lists
DNSRS_WHITELIST_URLS, DNSRS_WHITELIST_DOMAINS empty comma-separated lists
DNSRS_BLOCKLIST_REFRESH_SECS, DNSRS_WHITELIST_REFRESH_SECS 43200
DNSRS_BLOCK_MODE, DNSRS_SINKHOLE_IP nxdomain, 0.0.0.0
DNSRS_STATIC_HOSTS empty; format name=ip,name2=ip2
DNSRS_CACHE_ENABLED true
DNSRS_CACHE_MAX_SIZE_BYTES ¼ of cgroup memory, clamped to 1–64 MiB; ceiling 1 GiB
DNSRS_DEFAULT_TTL 300

Build the Fly profile:

docker build \
  --build-arg 'CARGO_ARGS=--no-default-features --features serverless,certless,dot,doh' \
  -t dns-rs-serverless .

A DoH-only platform profile (for example Azure) uses --no-default-features --features serverless,certless,doh.

Know before you deploy

  • No plaintext DNS on port 53. Devices that only speak classic DNS need a client-side proxy (stubby, dnscrypt-proxy) in front of this.
  • No DNSSEC validation.
  • No IPv6 in synthesized answers — static hosts and sinkhole mode produce A records only. AAAA queries against those names get NOERROR/NODATA, which still blocks them, just without a v6 sinkhole address.
  • No ACLs or auth. Anything that can reach the ports can query it. Firewall it like any other internal service.
  • Single instance, no clustering. For redundancy, run two independent instances behind DNS-level failover, not two sharing state.
  • This is a home-network / single-box resolver, not a BIND/Unbound/CoreDNS replacement at scale. It has not been fuzzed, load-tested, or security reviewed by anyone but its author.

Development

Latency tuning

The Fly configuration uses hedged upstreams: a slow primary gets a 25 ms head start before the next resolver joins. race starts every resolver at once, at the cost of more upstream traffic. These settings affect cache misses, not the network round trip between your device and this server.

Cache hits in the final 10% of TTL already trigger a background refresh. A one-second timer also checks the 1024 most recent entries, refreshing repeatedly used names in the final 5-10% of TTL (staggered per name). Names unused for five minutes stop refreshing. Both paths share four refresh slots, in-flight query deduplication, and failure backoff of roughly 1-16 seconds. Original queries, including EDNS options and DNSSEC flags, are retained within the cache byte budget; this reduces entry capacity slightly. TTLs are never extended without an upstream answer. Expired answers remain usable for the existing 30-second stale window with TTL zero.

Expired cache entries are swept in batches of 256 with yields between batches. Blocklist refresh merges run on a blocking worker, one rebuild at a time. Dead upstream DoT connections are replaced by maintenance with bounded retry backoff. Full cache flushes are unnecessary and cause cold misses.

For a short timing capture, set RUST_LOG=dns_rs::dns::handler=debug,dns_rs::dns::cache=debug. DNS query complete logs path (fresh, stale, miss, static, blocked) and elapsed_us; upstream query complete logs upstream wait separately. These are server processing times, excluding inbound TLS setup and network transit. Debug logging itself adds overhead; benchmark with RUST_LOG=warn.

Inbound DoT connections still close after 120 seconds without a new query. Client persistent-connection mode can therefore include occasional reconnects.

Checks

cargo test              # unit + integration tests, no network required
cargo clippy --all-targets

Tests use fake upstreams (no real network/TLS) to exercise fallback, race, caching, and negative-caching behavior — see dns::test_support.

About

An ad blocking dns server in rust

Resources

Stars

1 star

Watchers

0 watching

Forks

Packages

Contributors

Languages