Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

simple-dns-server

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.

How a request flows

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.com tries *.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.

Requirements

  • Rust (stable), Postgres 14+, Redis 6+, dig (bind-utils) for testing.

Quickstart

# 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 run

The server logs to stdout and to daily files in LOG_DIR (logs/dns-server.log.<date>).

Docker Compose (Postgres + DragonflyDB)

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.sql

Notes:

  • Seeding runs only when the pgdata volume is empty (standard docker-entrypoint-initdb.d behavior) — 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.1 only, not the LAN.

Running under systemd

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 too

The 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.

Configuration

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.

Cache stampede protection

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 Redis GETs and zero database hits. Deliberately short-lived (NEG_CACHE_TTL, default 30s) so newly added records appear quickly; bust manually with redis-cli DEL "neg:<name>:<TYPE>" if you can't wait.

Rate limiting

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).

Managing records

-- 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"

Testing

Unit tests (pure logic: name validation, wildcards, TTL clamping, TXT chunking, RCODE handling, rate limiter):

cargo test

Full 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 test

Coverage (needs llvm-tools + cargo-llvm-cov, same env vars as above):

cargo llvm-cov --summary-only

Live 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 +short

Repeat 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.

DNS-01 (Let's Encrypt) in brief

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.

Project structure

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

Production notes

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.

About

a simple dns server implemented using rust+tokio

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages