Chapter 32

DNS Server: UDP Network Programming

Chapter 32: DNS Server: UDP Network Programming

Every time you type github.com into your browser, something remarkable happens before any web content ever loads: your operating system sends a small UDP datagram โ€” typically around 53 bytes โ€” to a remote server. Tens of milliseconds later, a reply comes back, and only then does your machine begin the TCP connection to fetch the actual page. This is DNS resolution.

DNS (Domain Name System) is the internet's phonebook. It translates human-readable hostnames into machine-readable IP addresses. Without DNS you would need to memorize 140.82.114.4 to reach GitHub. But DNS is far more than a distributed dictionary lookup โ€” it is a hierarchical, decentralized, highly resilient system that handles trillions of queries every day across the globe.

This chapter dives into every corner of DNS and then uses Go to build a DNS proxy server from scratch. This is the ideal vehicle for learning UDP programming, and simultaneously a window into the design philosophy of foundational internet protocols.


Level 1 ยท What You Need to Know

How DNS Works: A Complete Query Journey

When you type www.google.com in your browser, the following sequence unfolds:

Step 1: Check the local cache

The operating system checks its DNS cache first. If the name was resolved recently and the cached TTL (Time To Live) has not expired, the cached address is returned immediately and the lookup ends there.

Step 2: Query the recursive resolver

On a cache miss, the OS sends a query to the configured DNS server โ€” typically your router, your ISP's resolver, or a public resolver like Google's 8.8.8.8 or Cloudflare's 1.1.1.1. This server is called the recursive resolver. It does the hard work on your behalf.

Step 3: The recursive resolver asks a root server

If the resolver also lacks a cached answer, it contacts one of the 13 DNS root server IP addresses (labeled A through M). Behind each IP sit hundreds of physical machines offering anycast service. The root servers do not know the IP for www.google.com, but they do know which servers are authoritative for .com โ€” they return those addresses.

Step 4: Ask the TLD server

The resolver contacts the .com TLD (top-level domain) server. The TLD server does not know the specific IP for www.google.com, but it knows which servers are authoritative for google.com โ€” it returns those.

Step 5: Ask the authoritative server

The resolver queries the authoritative server for google.com. The authoritative server is the ultimate source of truth โ€” it holds the actual DNS records and returns the IP address for www.google.com directly.

Step 6: Results flow back, each layer caches

The answer travels back along the chain. Each layer caches it according to the record's TTL. The whole round trip typically completes in under 100 ms.

Recursive Resolver vs. Authoritative Server

These two roles are often confused:

Type Role Examples
Recursive Resolver Performs iterative queries on behalf of clients; caches results 8.8.8.8 (Google), 1.1.1.1 (Cloudflare), your router
Authoritative Server Holds the actual zone records; answers definitively Cloudflare DNS, AWS Route 53, domain registrar servers

The recursive resolver is the industrious middleman: it does all the legwork but holds no original records. The authoritative server is the final authority: it answers only for its own zone and does not recurse.

Why DNS Uses UDP

DNS primarily operates over UDP on port 53. Why not TCP?

Advantages of UDP:

When TCP is used:

This is why every Go developer should understand DNS: it underpins every networked application you write. When your production service exhibits DNS resolution timeouts, when you need service discovery, when you are building an ad blocker or a security proxy โ€” all of these require a deep understanding of how DNS actually works.


Level 2 ยท How It Works Under the Hood

The DNS Wire Format

The DNS message format is defined in RFC 1035. It is a carefully designed compact binary format, and understanding it is the prerequisite for implementing anything DNS-related.

A DNS message consists of five sections:

+---------------------+
|        Header       |  12 bytes, fixed
+---------------------+
|       Question      |  the queried name and type
+---------------------+
|        Answer       |  resource record answers
+---------------------+
|      Authority      |  authoritative nameserver records
+---------------------+
|      Additional     |  additional helpful records
+---------------------+

Header layout (12 bytes):

 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                      ID                       |  2 bytes: transaction ID
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR|   Opcode  |AA|TC|RD|RA|   Z    |   RCODE   |  2 bytes: flags
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    QDCOUNT                    |  2 bytes: question count
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ANCOUNT                    |  2 bytes: answer record count
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    NSCOUNT                    |  2 bytes: authority record count
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|                    ARCOUNT                    |  2 bytes: additional record count
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

Key flags:

Domain name encoding and pointer compression

www.google.com is encoded as a sequence of length-prefixed labels:

3 w w w  6 g o o g l e  3 c o m  0

Each label is preceded by its one-byte length; the sequence ends with a zero byte. This is the length-prefix encoding.

DNS also uses pointer compression to save space. When the same domain name appears multiple times in a message, subsequent occurrences can be replaced by a two-byte pointer to the first occurrence. Pointers are identified by their high two bits being 11, meaning the first byte is 0xC0 or higher:

0xC0 0x0C  โ†’  pointer to offset 12 within the message

Any correct DNS parser must handle recursive pointer following. It must also guard against maliciously crafted messages containing circular pointer chains.

net.UDPConn: Low-Level UDP Programming

Go's net package offers two levels of UDP API:

High-level: net.Dial("udp", ...) returns a net.Conn. Convenient for clients; hides the remote address in the connection object.

Low-level: net.ListenUDP(...) returns *net.UDPConn. Required for servers, because each incoming datagram arrives from a potentially different client address that must be captured to route the reply:

conn, err := net.ListenUDP("udp", &net.UDPAddr{
    IP:   net.ParseIP("0.0.0.0"),
    Port: 53,
})
// ReadFromUDP returns both the payload and the sender's address
n, clientAddr, err := conn.ReadFromUDP(buf)
// WriteToUDP sends the response to the correct client
_, err = conn.WriteToUDP(response, clientAddr)

Critical performance note: UDP is datagram-oriented. Every ReadFromUDP call is an independent system call that delivers one complete datagram. Unlike a TCP stream, UDP preserves message boundaries โ€” you send 100 bytes, the receiver gets exactly 100 bytes, no fragmentation or coalescing (within the MTU limit). Never re-use the receive buffer before copying its contents.

Goroutine-per-Request vs. Worker Pool

The concurrency model for handling DNS queries has significant performance implications:

Goroutine-per-request:

ReadFromUDP โ†’ go func() for each query โ†’ process โ†’ WriteToUDP

Pros: simple and idiomatic. Cons: under DDoS conditions the server could spawn millions of goroutines. Each goroutine starts with a 2โ€“8 KB stack, so one million goroutines consume 2โ€“8 GB of memory.

Worker pool:

ReadFromUDP โ†’ buffered channel โ†’ fixed-size worker pool โ†’ WriteToUDP

Pros: memory-bounded, supports back-pressure. Cons: choosing the wrong pool size can under- or over-provision workers.

For a production DNS proxy, the worker pool is the right model. A sensible worker count: if upstream DNS latency averages 20 ms and you have 8 CPU cores, then roughly 8 ร— (1000 / 20) = 400 workers keeps all cores busy while waiting for upstream I/O.

DNS Caching and TTL Management

Caching is the key to DNS performance. Every resource record carries a TTL in seconds specifying how long it may be cached. Several subtleties matter:

TTL decrement: When you cache a record with TTL 300 and return it 150 seconds later, you must return the remaining 150 seconds as the TTL โ€” not the original 300. Failing to do this lets clients cache stale records longer than the authoritative server intended.

Negative caching: NXDOMAIN responses should also be cached, using the SOA record's minimum TTL. Without this, repeated queries for non-existent names hammer upstream servers unnecessarily.

Thread safety: The DNS cache is accessed by many worker goroutines concurrently. Protect it with sync.RWMutex or use sync.Map.


Level 3 ยท Code in Practice

Building a DNS Proxy/Forwarder

Below is a complete DNS proxy server with caching, upstream forwarding, and a local blocklist.

Message parsing (resolver.go):

package main

import (
    "encoding/binary"
    "errors"
    "fmt"
    "strings"
)

type DNSHeader struct {
    ID      uint16
    Flags   uint16
    QDCount uint16
    ANCount uint16
    NSCount uint16
    ARCount uint16
}

type DNSQuestion struct {
    Name  string
    Type  uint16
    Class uint16
}

func parseHeader(data []byte) (DNSHeader, error) {
    if len(data) < 12 {
        return DNSHeader{}, errors.New("DNS message too short")
    }
    return DNSHeader{
        ID:      binary.BigEndian.Uint16(data[0:2]),
        Flags:   binary.BigEndian.Uint16(data[2:4]),
        QDCount: binary.BigEndian.Uint16(data[4:6]),
        ANCount: binary.BigEndian.Uint16(data[6:8]),
        NSCount: binary.BigEndian.Uint16(data[8:10]),
        ARCount: binary.BigEndian.Uint16(data[10:12]),
    }, nil
}

// parseDomainName decodes a DNS name at offset, following pointer compression.
// Returns (name, next-field-offset, error).
func parseDomainName(data []byte, offset int) (string, int, error) {
    var labels []string
    visited := make(map[int]bool)
    originalOffset := -1

    for {
        if offset >= len(data) {
            return "", 0, errors.New("offset out of bounds")
        }
        if visited[offset] {
            return "", 0, errors.New("circular pointer in DNS name")
        }
        visited[offset] = true

        length := int(data[offset])

        switch {
        case length == 0:
            offset++
            if originalOffset != -1 {
                offset = originalOffset
            }
            return strings.Join(labels, "."), offset, nil

        case length&0xC0 == 0xC0:
            if offset+1 >= len(data) {
                return "", 0, errors.New("truncated pointer")
            }
            ptr := int(binary.BigEndian.Uint16(data[offset:offset+2]) & 0x3FFF)
            if originalOffset == -1 {
                originalOffset = offset + 2
            }
            offset = ptr

        case length&0xC0 == 0:
            offset++
            if offset+length > len(data) {
                return "", 0, errors.New("label exceeds data length")
            }
            labels = append(labels, string(data[offset:offset+length]))
            offset += length

        default:
            return "", 0, fmt.Errorf("invalid label type 0x%02X", length&0xC0)
        }
    }
}

func parseQuestion(data []byte, offset int) (DNSQuestion, int, error) {
    name, next, err := parseDomainName(data, offset)
    if err != nil {
        return DNSQuestion{}, 0, err
    }
    if next+4 > len(data) {
        return DNSQuestion{}, 0, errors.New("question section truncated")
    }
    return DNSQuestion{
        Name:  name,
        Type:  binary.BigEndian.Uint16(data[next : next+2]),
        Class: binary.BigEndian.Uint16(data[next+2 : next+4]),
    }, next + 4, nil
}

func cacheKey(name string, qtype uint16) string {
    return fmt.Sprintf("%s:%d", strings.ToLower(name), qtype)
}

TTL-aware cache (cache.go):

package main

import (
    "sync"
    "time"
)

type CacheEntry struct {
    Response  []byte
    ExpiresAt time.Time
}

type DNSCache struct {
    mu      sync.RWMutex
    entries map[string]*CacheEntry
}

func NewDNSCache() *DNSCache {
    c := &DNSCache{entries: make(map[string]*CacheEntry)}
    go c.evict()
    return c
}

func (c *DNSCache) Get(key string) ([]byte, bool) {
    c.mu.RLock()
    entry, ok := c.entries[key]
    c.mu.RUnlock()
    if !ok {
        return nil, false
    }
    remaining := time.Until(entry.ExpiresAt)
    if remaining <= 0 {
        c.mu.Lock()
        delete(c.entries, key)
        c.mu.Unlock()
        return nil, false
    }
    // Clone the response and patch all TTL fields with the remaining duration.
    clone := make([]byte, len(entry.Response))
    copy(clone, entry.Response)
    patchTTLs(clone, uint32(remaining.Seconds()))
    return clone, true
}

func (c *DNSCache) Set(key string, response []byte, ttl uint32) {
    if ttl == 0 {
        return
    }
    clone := make([]byte, len(response))
    copy(clone, response)
    c.mu.Lock()
    c.entries[key] = &CacheEntry{
        Response:  clone,
        ExpiresAt: time.Now().Add(time.Duration(ttl) * time.Second),
    }
    c.mu.Unlock()
}

func (c *DNSCache) evict() {
    t := time.NewTicker(30 * time.Second)
    for range t.C {
        now := time.Now()
        c.mu.Lock()
        for k, v := range c.entries {
            if now.After(v.ExpiresAt) {
                delete(c.entries, k)
            }
        }
        c.mu.Unlock()
    }
}

// patchTTLs rewrites the TTL field of every resource record in a DNS response.
func patchTTLs(resp []byte, ttl uint32) {
    if len(resp) < 12 {
        return
    }
    anCount := int(binary.BigEndian.Uint16(resp[6:8]))
    nsCount := int(binary.BigEndian.Uint16(resp[8:10]))
    arCount := int(binary.BigEndian.Uint16(resp[10:12]))
    total := anCount + nsCount + arCount

    offset := 12
    for i := 0; i < int(binary.BigEndian.Uint16(resp[4:6])); i++ {
        _, off, err := parseDomainName(resp, offset)
        if err != nil {
            return
        }
        offset = off + 4 // skip QTYPE + QCLASS
    }
    for i := 0; i < total; i++ {
        _, off, err := parseDomainName(resp, offset)
        if err != nil {
            return
        }
        offset = off + 4 // skip TYPE + CLASS
        if offset+6 > len(resp) {
            return
        }
        binary.BigEndian.PutUint32(resp[offset:offset+4], ttl)
        rdlen := int(binary.BigEndian.Uint16(resp[offset+4 : offset+6]))
        offset += 6 + rdlen
    }
}

Upstream UDP forwarder (upstream.go):

package main

import (
    "fmt"
    "net"
    "time"
)

type UpstreamResolver struct {
    servers []string
    timeout time.Duration
}

func NewUpstreamResolver(servers []string) *UpstreamResolver {
    return &UpstreamResolver{servers: servers, timeout: 5 * time.Second}
}

func (r *UpstreamResolver) Query(query []byte) ([]byte, error) {
    var lastErr error
    for _, srv := range r.servers {
        resp, err := r.queryOne(srv, query)
        if err == nil {
            return resp, nil
        }
        lastErr = err
    }
    return nil, fmt.Errorf("all upstreams failed: %w", lastErr)
}

func (r *UpstreamResolver) queryOne(server string, query []byte) ([]byte, error) {
    conn, err := net.DialTimeout("udp", server, r.timeout)
    if err != nil {
        return nil, err
    }
    defer conn.Close()
    conn.SetDeadline(time.Now().Add(r.timeout))

    if _, err = conn.Write(query); err != nil {
        return nil, err
    }
    buf := make([]byte, 4096)
    n, err := conn.Read(buf)
    if err != nil {
        return nil, err
    }
    return buf[:n], nil
}

DNS over HTTPS upstream (doh.go):

package main

import (
    "bytes"
    "fmt"
    "io"
    "net/http"
    "time"
)

type DoHResolver struct {
    url    string
    client *http.Client
}

func NewDoHResolver(url string) *DoHResolver {
    return &DoHResolver{url: url, client: &http.Client{Timeout: 5 * time.Second}}
}

// Query sends a DNS wireformat query via HTTPS (RFC 8484).
func (r *DoHResolver) Query(query []byte) ([]byte, error) {
    req, err := http.NewRequest("POST", r.url, bytes.NewReader(query))
    if err != nil {
        return nil, err
    }
    req.Header.Set("Content-Type", "application/dns-message")
    req.Header.Set("Accept", "application/dns-message")

    resp, err := r.client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("DoH request: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("DoH status %d", resp.StatusCode)
    }
    return io.ReadAll(io.LimitReader(resp.Body, 65535))
}

Blocklist and NXDOMAIN synthesis (blocklist.go):

package main

import (
    "bufio"
    "encoding/binary"
    "os"
    "strings"
    "sync"
)

type Blocklist struct {
    mu      sync.RWMutex
    domains map[string]struct{}
}

func NewBlocklist() *Blocklist {
    return &Blocklist{domains: make(map[string]struct{})}
}

func (b *Blocklist) LoadFromFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()

    b.mu.Lock()
    defer b.mu.Unlock()

    sc := bufio.NewScanner(f)
    for sc.Scan() {
        line := strings.TrimSpace(sc.Text())
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }
        fields := strings.Fields(line)
        if len(fields) >= 2 {
            b.domains[strings.ToLower(fields[1])] = struct{}{}
        }
    }
    return sc.Err()
}

func (b *Blocklist) IsBlocked(name string) bool {
    name = strings.ToLower(strings.TrimSuffix(name, "."))
    b.mu.RLock()
    defer b.mu.RUnlock()
    if _, ok := b.domains[name]; ok {
        return true
    }
    for {
        i := strings.Index(name, ".")
        if i == -1 {
            break
        }
        name = name[i+1:]
        if _, ok := b.domains[name]; ok {
            return true
        }
    }
    return false
}

func buildNXDOMAIN(query []byte) []byte {
    if len(query) < 12 {
        return nil
    }
    resp := make([]byte, len(query))
    copy(resp, query)
    flags := binary.BigEndian.Uint16(query[2:4])
    flags |= 0x8000 // QR=1 (response)
    flags |= 0x0003 // RCODE=3 (NXDOMAIN)
    flags &^= 0x0200 // AA=0
    binary.BigEndian.PutUint16(resp[2:4], flags)
    return resp
}

Main server loop (server.go):

package main

import (
    "encoding/binary"
    "log"
    "net"
    "sync"
)

type job struct {
    data []byte
    addr *net.UDPAddr
}

type Proxy struct {
    conn      *net.UDPConn
    cache     *DNSCache
    upstream  *UpstreamResolver
    doh       *DoHResolver
    blocklist *Blocklist
    jobs      chan job
}

func NewProxy(addr string, workers int) (*Proxy, error) {
    udpAddr, err := net.ResolveUDPAddr("udp", addr)
    if err != nil {
        return nil, err
    }
    conn, err := net.ListenUDP("udp", udpAddr)
    if err != nil {
        return nil, err
    }
    conn.SetReadBuffer(4 << 20)
    conn.SetWriteBuffer(4 << 20)

    p := &Proxy{
        conn:      conn,
        cache:     NewDNSCache(),
        upstream:  NewUpstreamResolver([]string{"8.8.8.8:53", "1.1.1.1:53"}),
        doh:       NewDoHResolver("https://cloudflare-dns.com/dns-query"),
        blocklist: NewBlocklist(),
        jobs:      make(chan job, workers*10),
    }
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); p.work() }()
    }
    return p, nil
}

func (p *Proxy) Run() {
    buf := make([]byte, 4096)
    for {
        n, addr, err := p.conn.ReadFromUDP(buf)
        if err != nil {
            log.Printf("read error: %v", err)
            continue
        }
        data := make([]byte, n)
        copy(data, buf[:n])
        select {
        case p.jobs <- job{data, addr}:
        default:
            log.Printf("pool full, dropping query from %s", addr)
        }
    }
}

func (p *Proxy) work() {
    for j := range p.jobs {
        if resp := p.handle(j.data); resp != nil {
            p.conn.WriteToUDP(resp, j.addr)
        }
    }
}

func (p *Proxy) handle(data []byte) []byte {
    hdr, err := parseHeader(data)
    if err != nil {
        return nil
    }
    q, _, err := parseQuestion(data, 12)
    if err != nil {
        return nil
    }

    if p.blocklist.IsBlocked(q.Name) {
        return buildNXDOMAIN(data)
    }

    key := cacheKey(q.Name, q.Type)
    if cached, ok := p.cache.Get(key); ok {
        binary.BigEndian.PutUint16(cached[0:2], hdr.ID)
        return cached
    }

    resp, err := p.upstream.Query(data)
    if err != nil {
        resp, err = p.doh.Query(data)
        if err != nil {
            return nil
        }
    }

    if ttl := minTTL(resp); ttl > 0 {
        p.cache.Set(key, resp, ttl)
    }
    return resp
}

func minTTL(resp []byte) uint32 {
    if len(resp) < 12 {
        return 0
    }
    anCount := int(binary.BigEndian.Uint16(resp[6:8]))
    if anCount == 0 {
        return 0
    }
    offset := 12
    for i := 0; i < int(binary.BigEndian.Uint16(resp[4:6])); i++ {
        _, off, err := parseDomainName(resp, offset)
        if err != nil {
            return 0
        }
        offset = off + 4
    }
    var min uint32 = ^uint32(0)
    for i := 0; i < anCount; i++ {
        _, off, err := parseDomainName(resp, offset)
        if err != nil {
            return 0
        }
        offset = off + 4
        if offset+6 > len(resp) {
            return 0
        }
        ttl := binary.BigEndian.Uint32(resp[offset : offset+4])
        if ttl < min {
            min = ttl
        }
        rdlen := int(binary.BigEndian.Uint16(resp[offset+4 : offset+6]))
        offset += 6 + rdlen
    }
    if min == ^uint32(0) {
        return 0
    }
    return min
}

func main() {
    proxy, err := NewProxy(":5353", 100)
    if err != nil {
        log.Fatalf("init: %v", err)
    }
    proxy.blocklist.LoadFromFile("blocklist.txt")
    log.Println("DNS proxy listening on :5353")
    proxy.Run()
}

Level 4 ยท Advanced Topics and Edge Cases

DNS Load Balancing: Round-Robin A Records

Round-robin DNS is the simplest form of load balancing. The authoritative server returns multiple A records for a single name, rotating their order on each response. Clients typically use the first address they receive.

In Go, the miekg/dns library makes this straightforward:

import "github.com/miekg/dns"
import "math/rand"

func handleQuery(w dns.ResponseWriter, r *dns.Msg) {
    m := new(dns.Msg)
    m.SetReply(r)
    m.Authoritative = true

    addrs := []string{"192.0.2.1", "192.0.2.2", "192.0.2.3"}
    // Shuffle for round-robin
    rand.Shuffle(len(addrs), func(i, j int) { addrs[i], addrs[j] = addrs[j], addrs[i] })

    for _, a := range addrs {
        m.Answer = append(m.Answer, &dns.A{
            Hdr: dns.RR_Header{
                Name: r.Question[0].Name, Rrtype: dns.TypeA,
                Class: dns.ClassINET, Ttl: 30,
            },
            A: net.ParseIP(a),
        })
    }
    w.WriteMsg(m)
}

Production-grade DNS load balancing combines round-robin with GeoDNS โ€” routing clients to the nearest server based on their IP's geographic location, using databases such as MaxMind GeoIP2. This requires binding to the same anycast IP from multiple data centers and ensuring the DNS server can identify the client's source IP (preserved through EDNS0 Client Subnet, RFC 7871).

The miekg/dns Library: A Deep Dive

Hand-rolling a DNS parser teaches you the protocol deeply. For production use, github.com/miekg/dns handles every edge case:

import "github.com/miekg/dns"

// Simple authoritative server with miekg/dns
func main() {
    mux := dns.NewServeMux()
    mux.HandleFunc("example.com.", func(w dns.ResponseWriter, r *dns.Msg) {
        m := new(dns.Msg)
        m.SetReply(r)
        m.Authoritative = true

        switch r.Question[0].Qtype {
        case dns.TypeA:
            m.Answer = append(m.Answer, &dns.A{
                Hdr: dns.RR_Header{
                    Name: "example.com.", Rrtype: dns.TypeA,
                    Class: dns.ClassINET, Ttl: 300,
                },
                A: net.ParseIP("93.184.216.34"),
            })
        case dns.TypeMX:
            m.Answer = append(m.Answer, &dns.MX{
                Hdr: dns.RR_Header{
                    Name: "example.com.", Rrtype: dns.TypeMX,
                    Class: dns.ClassINET, Ttl: 300,
                },
                Preference: 10,
                Mx:         "mail.example.com.",
            })
        }
        w.WriteMsg(m)
    })

    server := &dns.Server{Addr: ":53", Net: "udp", Handler: mux}
    log.Fatal(server.ListenAndServe())
}

The library handles EDNS0, DNSSEC signing and verification, zone file parsing, and all the record types defined in the various DNS RFCs.

DNSSEC Fundamentals

DNSSEC (DNS Security Extensions) uses public-key cryptography to guarantee the authenticity and integrity of DNS responses, preventing cache poisoning attacks. It adds several new record types:

The chain of trust starts at the root zone's trust anchor (the IANA root key, published by IANA), and verifies DS โ†’ DNSKEY โ†’ RRSIG at each delegation level all the way down to the leaf record.

DNS Amplification Attack Prevention

DNS reflection-amplification is a DDoS technique: the attacker spoofs the victim's IP address and sends queries to open resolvers, which flood the victim with large responses. Some query types (such as ANY or DNSKEY) have amplification ratios exceeding 100:1.

Defenses for DNS server developers:

// 1. Refuse or minimally answer ANY queries (RFC 8482)
if r.Question[0].Qtype == dns.TypeANY {
    m := new(dns.Msg)
    m.SetReply(r)
    m.Answer = append(m.Answer, &dns.HINFO{
        Hdr: dns.RR_Header{
            Name: r.Question[0].Name, Rrtype: dns.TypeHINFO,
            Class: dns.ClassINET, Ttl: 0,
        },
        Cpu: "ANY is obsolete",
        Os:  "See RFC 8482",
    })
    w.WriteMsg(m)
    return
}

// 2. Response Rate Limiting (RRL): throttle responses to the same source IP
// 3. Restrict recursion to authorized clients only (ACL by source IP)
// 4. Set TC=1 for large responses, forcing a TCP retry (TCP can't be spoofed)

EDNS0 Handling

EDNS0 (Extension Mechanisms for DNS, RFC 6891) extends the 512-byte UDP limit and adds capabilities like DNSSEC OK signals and client subnet information. When a client includes an OPT record in its query, the server should respect the advertised payload size:

// Extract EDNS0 payload size from incoming query
func edns0PayloadSize(r *dns.Msg) uint16 {
    if opt := r.IsEdns0(); opt != nil {
        if opt.UDPSize() > 512 {
            return opt.UDPSize()
        }
    }
    return 512 // RFC 1035 default
}

DNS is one of the internet's most critical pieces of infrastructure, and UDP programming in Go maps naturally to how DNS actually works at the wire level. From hand-parsing binary frames to deploying miekg/dns in production, from a simple forwarder to a full authoritative server โ€” each layer of this journey reveals the elegant engineering decisions that have kept DNS running reliably for four decades.

Rate this chapter
4.8  / 5  (3 ratings)

๐Ÿ’ฌ Comments