Skip to content

Repository files navigation

DNS SERVER

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.

Quick start

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 A

Metrics: curl http://127.0.0.1:8080/metrics · Health: curl http://127.0.0.1:8080/healthz

Architecture

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

Request lifecycle (server/handleRequest.js)

  1. Rate limit check (per source IP) — deny → silent drop or TC=1 "slip" reply.
  2. Decode + structural validation — malformed → dropped; invalid → FORMERR/NOTIMP.
  3. Cache lookup — hit → respond immediately with decremented TTLs.
  4. Concurrency-limiter check — saturated → immediate SERVFAIL (UDP has no backpressure of its own).
  5. singleflight.run(key, () => resolver.resolveRecord(question)) — concurrent duplicate queries share one resolution.
  6. Cache the result per RFC 2308 rules (SERVFAIL is never cached).
  7. Build and send the response, truncating (TC=1) if it exceeds the client's EDNS0 buffer size (or 512 bytes with no EDNS0).

System design notes

  • 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.randomInt transaction 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.json is watched; cache/rate-limit/concurrency knobs and log level hot-swap on change (port bindings and resolution mode require a restart).

Configuration

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.

Env var overrides

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

PowerShell:

$env:DNS_PORT=5300; npm start

Testing

  • 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:integration

Docker

docker compose up --build

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

About

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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages