Minimal authoritative DNS server in Rust. Serves A, CNAME and TXT records from Postgres, caches answers in Redis, and stores one history row per answered question. Speaks DNS over both UDP and TCP on a single address.
client -> UDP/TCP -> validate -> Redis -> Postgres (+ wildcard fallback)
-> encode response -> tracing event + dns_query_history row
- Exact match first, then parent-suffix wildcards (
a.b.example.comtries*.b.example.com, then*.example.com). Exact rows shadow wildcards. - A/CNAME serve a single row; TXT serves all rows on the label (needed for ACME DNS-01, where apex + wildcard share one challenge label).
- Backend failures answer SERVFAIL, never a false NXDOMAIN. Unknown types answer NODATA (empty NoError). Responses carry AA=1, RA=0.
- Abuse limits: 8 questions/message, 512 in-flight UDP packets, 256 TCP connections (256 queries each, 15s idle timeout), 2s backend timeouts, 1232-byte UDP cap with TC truncation.
- Rust (stable), Postgres 14+, Redis 6+,
dig(bind-utils) for testing.
# 1. Databases
createdb dns_db
psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f seed/database.sql
redis-server --daemonize yes # or your service manager
# 2. Config
cp .env.example .env # then edit credentials if needed
# 3. Run (dev port 53535, see LISTEN_ADDR)
cargo runThe server logs to stdout and to daily files in LOG_DIR
(logs/dns-server.log.<date>).
docker-compose.yml starts the infrastructure only: Postgres
(postgres:alpine, schema + example rows auto-seeded on first init) and
DragonflyDB as a drop-in Redis replacement (same protocol, redis:// URLs
unchanged). The app itself runs on the host under systemd (next section)
and reaches both backends on localhost.
docker compose up -d # first start creates + seeds dns_db
docker compose down # stop, keep data
docker compose down -v # wipe data; next up re-seeds from seed/database.sqlNotes:
- Seeding runs only when the
pgdatavolume is empty (standarddocker-entrypoint-initdb.dbehavior) — schema and the example rows. - Override the dev DB password (must match the unit file):
POSTGRES_PASSWORD=secret docker compose up -d. - Backend ports are published on
127.0.0.1only, not the LAN.
simple_dns.service runs the release binary as an ephemeral
unprivileged user (DynamicUser), with port 53 granted via
CAP_NET_BIND_SERVICE instead of root, a strict filesystem sandbox, and
logs + state in /var/log/simple_dns and /var/lib/simple_dns
(auto-created). Config lives in the unit's Environment= lines — keep
POSTGRES_PASSWORD in sync with compose.
# Install
cargo build --release
sudo cp target/release/simple_dns-server /usr/local/bin/
sudo cp simple_dns.service /etc/systemd/system/
sudo chmod 644 /etc/systemd/system/simple_dns.service
sudo systemctl daemon-reload
# Run / stop / status
sudo systemctl enable --now simple_dns # start now + on boot
dig @127.0.0.1 -p 53 example.com A +short
sudo systemctl stop simple_dns
sudo systemctl status simple_dns
journalctl -u simple_dns -f # stdout logs live here tooThe unit does not depend on the compose services: connection pools open lazily, so the app starts cleanly even if Postgres/Dragonfly are still coming up — queries just fail until they are reachable.
| Variable | Default | Meaning |
|---|---|---|
DATABASE_URL |
— (required) | Postgres connection string |
REDIS_URL |
— (required) | Redis connection string |
LISTEN_ADDR |
— (required) | Socket address for UDP+TCP, e.g. 0.0.0.0:53 |
DEFAULT_TTL |
— (required) | DNS TTL fallback for NULL/zero row TTLs (1–86400) |
CACHE_TTL_SECONDS |
— (required) | Redis entry expiry, independent of the DNS TTL (1–86400) |
LOG_DIR |
logs |
Rolling log file directory (created at startup) |
RUST_LOG |
warn |
Log verbosity, e.g. info to see every query |
ALLOW_INSECURE_REMOTE_DB |
0 |
Set to 1 only to allow non-localhost Postgres/Redis, which this build reaches over cleartext |
RRL_ENABLED |
true |
BIND-style response rate limiting master switch |
RRL_RPS |
20 |
Answers/sec per (client subnet, qname, qtype) before limiting |
RRL_SLIP |
2 |
Every Nth over-limit response slips (TC=1) instead of dropping |
RRL_WINDOW_SECS |
1 |
Rate window length in seconds |
RRL_IPV4_PREFIX / RRL_IPV6_PREFIX |
24 / 56 |
Client subnet aggregation for the rate key (0 disables) |
RRL_MAX_ENTRIES |
100000 |
Hard cap on tracked rate keys (bounds limiter memory; fails open past it) |
RRL_CLEANUP_SECS |
60 |
Stale-entry sweep interval |
NEG_CACHE_TTL |
30 |
Seconds an NXDOMAIN answer stays cached (1–3600); SERVFAIL is never cached |
Invalid values fail at startup with the offending variable named.
Two mechanisms, both verified by tests:
- Singleflight. Concurrent misses for the same cache key share one database fetch instead of stampeding Postgres — one leader fetches, the rest await the same future. Bounded (fails open past the in-flight cap) and shared across the UDP and TCP servers.
- Negative caching. Authoritative misses are cached briefly under a
neg:key, so a flood of repeat queries for nonexistent names costs two RedisGETs and zero database hits. Deliberately short-lived (NEG_CACHE_TTL, default 30s) so newly added records appear quickly; bust manually withredis-cli DEL "neg:<name>:<TYPE>"if you can't wait.
Responses are rate-limited per (client subnet, qname, qtype) over a fixed
window — the same model as BIND RRL. Past RRL_RPS, responses are dropped
silently, except every RRL_SLIP-th one, which is answered truncated
(TC=1, no answers) so legitimate clients retry over TCP. Drops skip
Redis/Postgres entirely; invalid names and unsupported types never consume
budget. Fully-dropped messages send nothing and are logged with status
DROPPED/SLIPPED in both the log file and dns_query_history. Memory is
bounded by RRL_MAX_ENTRIES (stale entries swept every RRL_CLEANUP_SECS,
fail-open past the cap).
-- A / CNAME: one row served per (name, type)
INSERT INTO dns_records (name, type, value, ttl)
VALUES ('example.com', 'A', '93.184.216.34', 300);
-- TXT: several values may share one label
INSERT INTO dns_records (name, type, value, ttl) VALUES
('_acme-challenge.example.com', 'TXT', 'token-for-apex', 60),
('_acme-challenge.example.com', 'TXT', 'token-for-wildcard', 60);
-- Revoke without deleting
UPDATE dns_records SET enabled = false WHERE name = 'old.example.com';
-- Bust the cache after manual edits (NXDOMAIN answers are not cached,
-- but a changed value is served stale until expiry)
-- redis-cli DEL "dns:example.com:A"Unit tests (pure logic: name validation, wildcards, TTL clamping, TXT chunking, RCODE handling, rate limiter):
cargo testFull suite with live Postgres + Redis (78 tests, ~11s):
TEST_DATABASE_URL=postgres://test_user@127.0.0.1:55433/dns_test \
TEST_REDIS_URL=redis://127.0.0.1:6399 cargo testCoverage (needs llvm-tools + cargo-llvm-cov, same env vars as above):
cargo llvm-cov --summary-onlyLive queries with dig (examples assume LISTEN_ADDR=127.0.0.1:53535):
# A over UDP
dig @127.0.0.1 -p 53535 example.com A +short
#=> 93.184.216.34
# CNAME
dig @127.0.0.1 -p 53535 www.example.com CNAME +short
#=> example.com.
# TXT, all values on the label
dig @127.0.0.1 -p 53535 _acme-challenge.example.com TXT +short
#=> "token-for-apex"
#=> "token-for-wildcard"
# TCP transport (note +tcp)
dig @127.0.0.1 -p 53535 example.com A +tcp +short
# Missing name -> NXDOMAIN, empty answer
dig @127.0.0.1 -p 53535 no-such.example.com A +short
#=> (no output; status: NXDOMAIN)
# Unsupported type -> NODATA (NoError, no answers, not NXDOMAIN)
dig @127.0.0.1 -p 53535 example.com MX +shortRepeat a query and watch from_cache flip to true — the second answer
comes from Redis:
# Per-question structured log (needs RUST_LOG=info)
tail -f logs/dns-server.log.* | grep "dns query"
# Same data in Postgres
psql "$DATABASE_URL" -c \
"SELECT qname, qtype, status, rcode, from_cache, elapsed_ms, created_at
FROM dns_query_history ORDER BY id DESC LIMIT 10;"status is the per-question outcome (HIT, NODATA, NXDOMAIN,
SERVFAIL, REFUSED, ...), rcode the RCODE sent, elapsed_ms the
handler time excluding socket I/O.
Request with certbot certonly --manual --preferred-challenges dns -d 'example.com' -d '*.example.com', publish each shown token as a TXT row
under _acme-challenge.example.com (ttl 60), delete the Redis key
dns:_acme-challenge.example.com:TXT, verify with
dig @<server> _acme-challenge.example.com TXT +short, then let certbot
proceed. Automate renewal with --manual-auth-hook / --manual-cleanup-hook
scripts that INSERT/DELETE the row plus the cache key.
src/main.rs startup, logging, UDP+TCP tasks, shutdown
src/config.rs env config + fail-fast validation
src/dns.rs request pipeline, validation, RRL, limits, unit tests
src/db.rs Postgres pool, lookups, history insert
src/redis_cache.rs Redis/Dragonfly answer cache (single + multi-value payloads)
src/rrl.rs BIND-style response rate limiter + unit tests
seed/database.sql full schema + example rows (idempotent)
docker-compose.yml Postgres (alpine) + DragonflyDB infra (app runs on host)
simple_dns.service systemd unit: unprivileged run on port 53, run/stop
Run as an unprivileged user (grant port 53 via
CAP_NET_BIND_SERVICE, not root), terminate TLS to Postgres/Redis or keep
them on localhost, set up cargo audit in CI, retain/rotate logs/ and
dns_query_history (e.g. delete rows older than 30 days), and put rate
limiting in front before exposing port 53 to the internet.