Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

dns-resolver

A from-scratch recursive DNS resolver in Go, with a TTL-aware cache and first-class observability — Prometheus metrics, structured logs, and OpenTelemetry traces — wired through a single instrumentation seam.

It speaks the DNS wire protocol by hand (no miekg/dns), walks the delegation tree from the root servers, and ships with a one-command Grafana stack so you can watch it work.

CI Go License


Why this project

DNS is one of the few places where you can see the whole internet's naming system work from first principles, and it is a natural showcase for two skills at once:

  • Networking — the resolver builds and parses raw DNS packets (RFC 1035), handles UDP with TCP fallback, follows NS referrals and glue, chases CNAME chains, and does negative caching per RFC 2308. None of this is delegated to a library.
  • Observability — every query emits metrics, logs, and a distributed trace. The instrumentation lives behind one Observer type, so the resolver has a single dependency to reason about and adding a new signal touches one file.

Architecture

flowchart LR
    client["dig / stub resolver"] -->|"UDP / TCP :53"| server

    subgraph proc["resolver process"]
        server["DNS server<br/>(UDP + TCP)"] --> res["recursive resolver"]
        res <-->|"hit / miss"| cache["TTL cache<br/>(+ negative cache)"]
        res -->|"iterative queries"| client2["wire-protocol client"]
        server -. instruments .-> obs["Observer"]
        res -. instruments .-> obs
        obs --> met["Prometheus /metrics"]
        obs --> logs["slog (JSON)"]
        obs --> otel["OTLP traces"]
    end

    client2 -->|"RD=0"| roots["root servers"]
    client2 --> tld["TLD servers (.com)"]
    client2 --> auth["authoritative servers"]

    met -->|scrape| prom["Prometheus"] --> graf["Grafana"]
    otel --> coll["OTel Collector"]
Loading

A single query flows: server → cache lookup → (on miss) recursive walk → cache store → response, and the whole journey is one trace, with a child span per upstream exchange.

Features

  • Hand-rolled DNS codec — header, question, and resource-record (de)serialisation with compression-pointer support and explicit loop-bounding so malicious packets can't spin the parser.
  • Iterative recursive resolution — starts at the 13 root servers, follows delegations down to the authoritative zone, using glue records when present and resolving nameserver addresses on demand when not.
  • CNAME chasing both within a single response and across responses.
  • TTL-aware cache — record TTLs count down in real time; entries expire at the minimum TTL of the set; bounded size with nearest-to-expiry eviction and a background sweeper.
  • Negative caching (NXDOMAIN / NODATA) governed by the SOA minimum.
  • UDP with automatic TCP fallback on truncated (TC) responses, both as a client and as a server.
  • Two modesrecursive (walk the roots yourself) or forward (front an existing upstream like 1.1.1.1, useful behind restrictive networks).
  • Observability built in — Prometheus metrics, JSON structured logs with per-query context, and OpenTelemetry spans, plus pluggable user Hooks.
  • Security touches — cryptographically random query IDs (off-path poisoning defence) and strict bounds on name length, label count, and referral/CNAME depth.

Quick start

Run locally

# Recursive from the root servers (port 15353 to avoid needing root):
make run-recursive            # or: go run ./cmd/resolver -mode recursive -dns :15353

# In another terminal:
dig @127.0.0.1 -p 15353 example.com A
dig @127.0.0.1 -p 15353 www.github.com A      # watch the CNAME get followed
curl -s localhost:9153/metrics | grep ^dns_  # the metrics it just produced

Example session:

$ dig @127.0.0.1 -p 15353 example.com A +noall +answer +stats
example.com.   300  IN  A  93.184.216.34
;; Query time: 69 msec          # first query: full recursion from the roots

$ dig @127.0.0.1 -p 15353 example.com A +noall +stats
;; Query time: 0 msec           # second query: served from cache

Run the full observability stack

make compose-up      # resolver + Prometheus + Grafana + OTel collector
Service URL Notes
Resolver dig @127.0.0.1 -p 15353 … DNS on 15353, metrics on 9153
Prometheus http://localhost:9080 scrapes the resolver every 5s
Grafana http://localhost:3000 "DNS Resolver" dashboard, no login
OTel traces collector logs swap in Jaeger/Tempo for a UI

Generate some load and watch the Grafana dashboard light up:

for d in example.com cloudflare.com github.com wikipedia.org openai.com; do
  dig @127.0.0.1 -p 15353 "$d" A +short
done

Observability

Metrics (/metrics)

Metric Type Labels
dns_resolver_queries_total counter protocol,qtype
dns_resolver_responses_total counter rcode
dns_resolver_resolve_duration_seconds histogram source
dns_resolver_inflight_queries gauge
dns_cache_lookups_total counter result
dns_cache_entries / dns_cache_hit_ratio gauge
dns_upstream_queries_total counter outcome
dns_upstream_rtt_seconds histogram

The bundled Grafana dashboard plots query rate by type, response codes, cache hit ratio, p50/p95/p99 resolve latency split by cache-vs-recursive, and upstream RTT/outcomes.

Tracing

go run ./cmd/resolver -trace stdout            # pretty-print spans locally
go run ./cmd/resolver -trace otlp -trace-endpoint localhost:4318

Each client query is a root span; each upstream exchange (root → TLD → auth) is a child span annotated with the server, RTT, and byte counts — so a slow recursion shows you exactly which delegation step was slow.

Logs

Structured log/slog output, JSON by default (-log-format text for local tailing):

{"time":"...","level":"INFO","msg":"query complete","name":"example.com.","qtype":"A","rcode":"NOERROR","source":"recursive","duration":"69ms"}

Custom hooks

observability.Hooks lets an embedder bolt on behaviour (audit feed, rate-limit signal, SIEM export) without touching the resolver:

obs := observability.NewObserver(metrics, logger, tracer, observability.Hooks{
    OnQueryComplete: func(ctx context.Context, q dns.Question, rc dns.RCode, d time.Duration, src string, err error) {
        // ship to your own pipeline
    },
})

Project layout

cmd/resolver/            entrypoint, flag parsing, wiring
internal/dns/            hand-rolled wire-protocol codec (RFC 1035) + tests/bench
internal/cache/          TTL + negative cache with bounded eviction
internal/resolver/       iterative recursive resolution, root hints, UDP/TCP client
internal/server/         UDP + TCP DNS listeners and the metrics/health HTTP server
internal/observability/  Observer seam: Prometheus, slog, OpenTelemetry
deploy/                  docker-compose stack, Prometheus, Grafana, OTel configs

Design notes

A few decisions worth calling out in a code walkthrough:

  • Compression-pointer safety. Names can contain pointers back into the message; a crafted packet can form a loop. decodeName caps pointer jumps and total name length, so parsing is always bounded.
  • TTL is a countdown, not a constant. Cached records have their TTL decremented by the elapsed time on every read, so a downstream client never receives a record claiming more freshness than it actually has.
  • Minimum-TTL for a record set. An RRset is only as fresh as its least-fresh member, so the whole entry expires at the smallest TTL.
  • Negative caching uses the SOA minimum, capped by the SOA record's own TTL (RFC 2308), so NXDOMAIN/NODATA answers don't trigger a fresh recursion every time.
  • Glue vs. on-demand NS resolution. Referrals are followed using glue records when the parent supplies them; otherwise the resolver resolves the nameserver's address itself, bounded by a recursion-depth limit.
  • Random query IDs from crypto/rand raise the bar for off-path cache poisoning, and unmatched responses are ignored.

Testing

make test        # unit + integration tests
make test-race   # with the race detector
make bench       # wire-codec benchmarks
make cover       # HTML coverage report

The tests stand up in-process mock authoritative servers — including a single mock that role-plays root → TLD → authoritative — so the full referral-with-glue walk is exercised without touching the network. The codec has round-trip and real-packet tests (compression pointers, SOA, CNAME, NXDOMAIN), and the cache has deterministic TTL/expiry/eviction tests on an injected clock.

BenchmarkPack-11      3072747    357.9 ns/op    840 B/op   13 allocs/op
BenchmarkUnpack-11    1911520    650.9 ns/op    832 B/op   36 allocs/op

Limitations / roadmap

This is a portfolio project, not a production resolver. Deliberately out of scope (but natural next steps):

  • DNSSEC validation — the highest-value addition; currently records are not cryptographically validated.
  • EDNS0 advertising of larger UDP buffers (the code handles TCP fallback instead).
  • Query de-duplication / in-flight coalescing for identical concurrent lookups.
  • Prefetching of popular records before their TTL expires.

License

MIT — see LICENSE.

About

DNS Resolver with Observability Stack. The Irish word "réitigh" primarily translates to "to solve", "to arrange", or "to prepare" in English

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages