A production-grade recursive DNS server built from scratch in Node.js. Supports static, forwarding, and iterative recursive resolution (root → TLD → authoritative) over UDP, with a TTL-aware cache, request coalescing, exponential-backoff retries with per-nameserver circuit breaking, and per-IP rate limiting.
npm install
npm test # full unit + integration suite (hermetic, no live internet needed)
npm start # runs the server using config/default.json (UDP :5353, metrics :8080)Query it (static mode ships with a sample zone in config/zones.static.json):
dig @127.0.0.1 -p 5353 example.test AMetrics: curl http://127.0.0.1:8080/metrics · Health: curl http://127.0.0.1:8080/healthz
src/
server/ UDP socket lifecycle (startServer/stopServer), the handleRequest pipeline, sendResponse (EDNS0/TC=1)
parser/ dns-packet decode/encode wrappers, header/question/answer extraction
protocol/ buildQuery, buildResponse, validatePacket (RFC 1035 structural checks)
resolver/ mode dispatch + resolveStatic / resolveForward / resolveRecursive, root hints, CNAME-chain guard
records/ per-type (A/AAAA/CNAME/MX/NS/TXT/PTR/SOA/SRV) formatters
cache/ TTL-aware LRU response cache, RFC 2308 negative caching
singleflight/ in-flight resolution coalescing (the "idempotency" layer)
ratelimit/ per-source-IP token bucket + RRL-style slip/drop
upstream/ queryUpstream (validated, source-port-randomized), retry.js (backoff), circuitBreaker.js
metrics/ in-memory counters + Express /metrics, /healthz
config/ load/validate/hot-reload (config/default.json)
utils/ UDP socket helper, timeout helper, concurrency limiter, shutdown orchestrator
- Rate limit check (per source IP) — deny → silent drop or TC=1 "slip" reply.
- Decode + structural validation — malformed → dropped; invalid → FORMERR/NOTIMP.
- Cache lookup — hit → respond immediately with decremented TTLs.
- Concurrency-limiter check — saturated → immediate SERVFAIL (UDP has no backpressure of its own).
singleflight.run(key, () => resolver.resolveRecord(question))— concurrent duplicate queries share one resolution.- Cache the result per RFC 2308 rules (SERVFAIL is never cached).
- Build and send the response, truncating (TC=1) if it exceeds the client's EDNS0 buffer size (or 512 bytes with no EDNS0).
- Caching (
src/cache): key =name|type|class, LRU-bounded, absolute-expiry entries with TTLs decremented on read. Negative responses (NXDOMAIN/NODATA) are cached using the enclosing zone's SOA MINIMUM per RFC 2308; SERVFAIL is never cached. - Idempotency / singleflight (
src/singleflight): duplicate concurrent queries for the same qname+qtype share a single in-flight resolution instead of each hammering upstream — the classic thundering-herd defense for cache-miss storms. - Retries / circuit breaking (
src/upstream): exponential backoff with full jitter between repeated attempts against the same nameserver; immediate rotation to the next nameserver on failure; a per-nameserver circuit breaker skips consistently failing servers for a cooldown window. - Rate limiting (
src/ratelimit): O(1) lazy-refill token bucket per source IP, bounded by an LRU map so spoofed-source floods can't exhaust memory. Denials alternate between silent drops and TC=1 "slips" (RRL-style anti-amplification). - Anti-poisoning:
crypto.randomInttransaction IDs, fresh ephemeral source port per outbound query, and strict validation that upstream replies match the outstanding query's address/port/id/question before being accepted. - Bounded concurrency + graceful shutdown: an in-flight resolution cap fails fast under saturation; shutdown stops accepting new datagrams immediately, drains in-flight work up to a deadline, then closes.
- Config hot-reload:
config/default.jsonis watched; cache/rate-limit/concurrency knobs and log level hot-swap on change (port bindings and resolution mode require a restart).
See config/default.json. Key fields: server.udpPort (default 5353, non-privileged), resolver.mode (static | forward | recursive), cache.*, rateLimit.*, upstream.* (timeouts/retries/circuit breaker). resolver.mode=forward requires a non-empty forwarders list; recursive uses config/root-hints.json.
Useful when the default port collides with something already running locally (e.g. an mDNS service on 5353) — no need to edit the config file:
DNS_PORT=5300 npm start # override server.udpPort
DNS_HOST=127.0.0.1 npm start # override server.udpHost
METRICS_PORT=9090 npm start # override server.metricsPort
METRICS_HOST=127.0.0.1 npm start # override server.metricsHostPowerShell:
$env:DNS_PORT=5300; npm start- Unit tests: pure-logic modules (parser, protocol, cache, singleflight, rate limiter, retry/circuit-breaker, resolvers) with mocked I/O.
- Integration tests: real UDP against an ephemeral-port server instance, including a fully offline 3-tier fake root/TLD/authoritative hierarchy (
test/helpers/fakeServers.js) for recursive-mode tests — no live internet or real root servers required.
npm run test:unit
npm run test:integrationdocker compose up --buildExposes UDP 5353 and HTTP 8080 (metrics). Runs as a non-root user; config/ is mounted read-only so zone/root-hints edits don't require a rebuild.