DNS Client

Overview

This document defines the public contract for the dns module (src/dns/).

Pure Lua DNS client with recursive resolution, caching, CNAME following, search list expansion, server failover, retry, and EDNS support. Transports: UDP (default with TC→TCP fallback), TCP, and DNS-over-TLS.

Includes parse_resolv_conf() for system nameserver discovery. Recursive resolution (RD=1) for normal use, plus iterative resolution helpers (RD=0) for walking the DNS hierarchy step by step.

Client construction

dns.new(cfg) (or dns.client.new(cfg)) creates a resolver.

local dns = require("dns")

-- Simple resolver
local resolver, err = dns.new({ servers = { "1.1.1.1" } })

-- With caching and search list
local cache = require("dns.cache")
local resolver = dns.new({
    servers = { "10.0.0.1" },
    timeout = 3,
    retries = 3,
    search  = { "internal.corp.", "prod.corp." },
    ndots   = 2,
    cache   = cache.new({ min_ttl = 60 }),
})

-- DNS-over-TLS (port 853)
local resolver = dns.new({
    servers = { "1.1.1.1" },
    use_tls = true,
})

Config fields

FieldTypeDefaultDescription
servers{string\|table}requiredUpstream DNS servers; entries can be IP strings ("1.1.1.1") or tables ({host = "1.1.1.1", port = 5353}); port defaults to 53 (853 for TLS)
timeoutnumber5Socket timeout (seconds)
retriesnumber2Retry attempts per query
use_tcpbooleanfalseForce TCP for all queries
use_tlsbooleanfalseUse DNS-over-TLS (port 853)
ndotsnumber1Search list dot threshold
search{string}{}Search domain list
edns_buffer_sizenumber4096EDNS advertised UDP payload size
use_0x20booleanfalse0x20 query name encoding
cachetablenildns.cache instance for caching
server_namestringnilTLS SNI hostname (TLS only)
cafilestringnilPath to CA certificate bundle (TLS only)
capathstringnilPath to CA certificate directory (TLS only)
verifybooleantrueTLS certificate verification (TLS only)

Returns nil, err if servers is missing or empty.

dns.client.parse_resolv_conf(path)

Parses a resolv.conf file and returns a config table suitable for passing directly to dns.client.new(). Defaults to /etc/resolv.conf.

Parsed directives: nameserverservers, search/domainsearch, options ndots:Nndots, options timeout:Ntimeout, options attempts:Nretries.

-- Use system resolvers
local sys = dns.client.parse_resolv_conf()
local resolver = dns.new(sys)

-- Merge with overrides
local sys = dns.client.parse_resolv_conf()
sys.timeout = 3
sys.use_tls = true
local resolver = dns.new(sys)

Returns nil, err if the file cannot be read.

Client API

resolver:resolve(qname, qtype)

Resolves qname by querying upstream servers with retry, failover, and optional caching. Follows CNAME chains (depth limit 10) and applies search list expansion based on ndots.

qtype is a record type string ("A", "AAAA", "MX") or number.

Returns a list of matching answer records on success, nil, err on failure.

local answers, err = resolver:resolve("example.com", "A")
if not answers then
    print("error: " .. err)
else
    for _, rr in ipairs(answers) do
        print(rr.rdata.address, "TTL", rr.ttl)
    end
end

resolver:query(raw_bytes)

Lower-level interface. Sends a pre-encoded DNS message, returns the decoded response. No caching, no CNAME following, no response validation.

resolver:close()

Closes transport sockets. Call when done with the resolver.

Record format

Each record returned by resolve() has:

{
    name  = "example.com.",  -- FQDN with trailing dot
    type  = 1,               -- numeric type code
    class = 1,               -- numeric class code (1 = IN)
    ttl   = 300,             -- seconds
    rdata = { ... },         -- type-specific fields (see below)
}

RDATA by type

TypeFields
Aaddress — IPv4 string ("93.184.216.34")
AAAAaddress — IPv6 string ("2606:2800:21f:cb07:6820:80da:af6b:8b2c")
CNAMEcname — canonical name
NSnsdname — nameserver FQDN
PTRptrdname — pointer domain name
MXpreference (number), exchange (FQDN)
SOAmname, rname, serial, refresh, retry, expire, minimum
TXTstrings (array), text (concatenated string)
SRVpriority, weight, port (numbers), target (FQDN)
DNSKEYflags (number), protocol (number), algorithm (number), public_key (binary string)
RRSIGtype_covered (number), algorithm (number), labels (number), original_ttl (number), sig_expiration (number), sig_inception (number), key_tag (number), signer_name (FQDN), signature (binary string)
DSkey_tag (number), algorithm (number), digest_type (number), digest (binary string)
NSECnext_domain (FQDN), types (set of type numbers, {[1]=true, [28]=true, ...})
NSEC3hash_algo (number), flags (number), iterations (number), salt (binary string), next_hashed_owner (binary string), types (set of type numbers)
Unknownraw — raw bytes

Cache

dns.cache.new(cfg) creates a TTL-aware record cache. Two backends:

local cache = require("dns.cache")

-- In-memory (default)
local c = cache.new({ max_entries = 10000, min_ttl = 30, max_ttl = 86400 })

-- MNEME-backed (persistent)
local c = cache.new({
    min_ttl = 30,
    max_ttl = 86400,
    mneme = true,
    mneme_path = "/var/cache/recall/dns.mneme",  -- optional
})

-- MNEME with pre-opened db handle (for sharing db between cache and other consumers)
local mneme = require("mneme")
local db = mneme.open("/var/cache/recall/dns.mneme")
local c = cache.new({ mneme_db = db })

The API is identical regardless of backend.

Cache config

FieldDefaultDescription
min_ttl30Minimum TTL floor (seconds)
max_ttl86400Maximum TTL cap (seconds)
max_negative_ttl300Negative response TTL cap
max_entries10000Max positive cache entries (in-memory)
max_negative5000Max negative cache entries (in-memory)
sweep_interval120Seconds between expired-entry sweeps (in-memory)
mnemefalseEnable MNEME backend (opens db at mneme_path)
mneme_dbnilPre-opened MNEME db handle (overrides mneme/mneme_path)
mneme_path/var/cache/recall/dns.mnemeMNEME database file path

Cache API

Types and constants

local types = require("dns.types")

types.TYPE      -- { A = 1, AAAA = 28, MX = 15, ... }
types.TYPE_NAME -- { [1] = "A", [28] = "AAAA", ... }
types.RCODE     -- { NOERROR = 0, NXDOMAIN = 3, SERVFAIL = 2, ... }
types.RCODE_NAME
types.CLASS     -- { IN = 1, CH = 3, ... }
types.CLASS_NAME
types.OPCODE
types.OPCODE_NAME

Shell dig builtin

dig <domain> [type] [@server] [options]

Examples:

dig example.com                    # A record, system resolvers
dig example.com AAAA               # AAAA record
dig example.com MX @8.8.8.8       # MX records via Google DNS
dig example.com -t TXT             # TXT records
dig example.com --tcp              # Force TCP
dig example.com --tls              # DNS-over-TLS
dig example.com --timeout 10       # 10 second timeout
dig example.com -n 9.9.9.9        # Quad9 nameserver
dig example.com @127.0.0.1 -p 5353  # Custom port
dig example.com -i                 # Iterative trace from root servers
dig example.com AAAA -i            # Iterative trace for AAAA
dig example.com -d                 # Iterative trace with DNSSEC validation
dig example.com -i -d              # Same (--dnssec implies --iterative)

Options:

Iterative resolution

dns.iter provides helpers for walking the DNS hierarchy from root servers to authoritative answers using iterative queries (RD=0). Useful for diagnostics and understanding DNS resolution chains.

iter.ROOT_SERVERS

Table of the 13 IANA root name servers. Each entry has name (FQDN), host (IPv4), and v6 (IPv6) fields.

iter.query_server(host, qname, qtype, opts)

Send a single iterative (RD=0) query to a specific server IP. Returns the full decoded response message including all sections.

qtype may be a string ("A", "NS") or numeric code. Automatically retries over TCP on truncation (TC bit).

local iter = require("dns.iter")
local resp, err = iter.query_server("198.41.0.4", "example.com", "A")

Options: port (default 53), timeout (default 5), edns_buffer_size (default 4096, false to disable EDNS).

iter.classify(resp, qtype, qname, dnssec_mode)

Classify a decoded DNS response. Returns kind, info where kind is one of:

KindConditionInfo fields
"answer"Matching records in answer sectionrecords, cname_records, authoritative
"cname"CNAME without matching qtype recordstarget
"referral"NS in authority, no answerzone, ns, glue
"nxdomain"RCODE 3soa (or nil)
"nodata"NOERROR, empty answer, no NSsoa (or nil)
"error"SERVFAIL, REFUSED, etc.rcode, rcode_name

qname is the normalized (lowercased, trailing-dot) owner name used to match answer and CNAME records — it must be passed for the answer/cname classifications to work. dnssec_mode (boolean, default false) opts into collecting the response's DNSSEC records (RRSIG, DS, DNSKEY, NSEC, NSEC3) into info.dnssec.

local qname = dns_name.normalize("example.com")
local kind, info = iter.classify(resp, 1, qname)  -- 1 = TYPE.A
if kind == "referral" then
    print("delegated to zone:", info.zone)
    for _, ns in ipairs(info.ns) do
        local g = info.glue[ns.name]
        print("  ", ns.name, g and g.ipv4 or "(no glue)")
    end
end

iter.extract_referral(resp)

Parse referral details from a response. Returns zone, ns, glue where ns is a list of {name = "..."} tables and glue maps NS names to {ipv4 = "...", ipv6 = "..."}.

iter.trace(qname, qtype, opts)

Walk the full DNS hierarchy from root servers to authoritative answer. Returns a list of step tables, each with:

local iter = require("dns.iter")
local steps, err = iter.trace("example.com", "A")
if steps then
    for i, step in ipairs(steps) do
        print(i, step.server, step.kind, step.zone, step.rtt_ms .. "ms")
    end
end

Options: timeout (default 5), max_referrals (default 30), max_cnames (default 10), max_glue_depth (default 2), root_servers (override), edns_buffer_size (default 4096), dnssec (default false).

When dnssec = true, the trace sets the EDNS DO (DNSSEC OK) bit on all queries and performs chain-of-trust validation at each step. Each returned step gains two additional fields:

Validation starts from the IANA root trust anchor (KSK 20326) and follows DS → DNSKEY → RRSIG chains down through each delegation. At each new zone, the resolver fetches the zone's DNSKEY RRset and validates it against the parent's DS records. Answer RRsets are then verified against the zone's validated keys. Wildcard-expanded records are handled correctly per RFC 4035 §5.3.4 (label count comparison and owner name reconstruction).

CNAME responses are DNSSEC-validated before following: the CNAME RRset's RRSIG is verified against the zone's validated keys. If validation fails, the CNAME is marked "bogus" and the trace aborts (the forged CNAME is not followed). For nodata/nxdomain responses, the SOA RRSIG in the authority section is cryptographically validated.

Unsigned answers from signed zones (zones with validated DNSKEY RRsets) are marked "bogus", not "insecure" — only zones without a DS chain are considered insecure.

CNAME chains restart resolution from root for the CNAME target. Glueless delegations (NS without A/AAAA glue) are resolved via a recursive sub-trace for the NS name's A record. The max_glue_depth option limits nesting depth to prevent infinite loops (default 2). The sub-trace steps are stored in info.glue_trace on the referral step that triggered them. Sub-traces inherit the parent's DNSSEC validation state.

DNSSEC validation

dns.dnssec provides DNSSEC validation primitives used by iter.trace and the RECALL resolver.

Supported algorithms

IDAlgorithmKey size
8RSA/SHA-256variable
10RSA/SHA-512variable
13ECDSA P-256/SHA-25664 bytes
14ECDSA P-384/SHA-38496 bytes
15ED2551932 bytes

DS digest types: SHA-1 (1), SHA-256 (2), SHA-384 (4).

Zones signed with unsupported algorithms are reported as indeterminate rather than insecure, so they can be distinguished from unsigned zones.

Validation coverage

Positive responses (answer, CNAME):

Negative responses (NXDOMAIN, NODATA):

Not implemented

Module API

Security

Architecture

dns (top-level re-export)
├── dns.client     — high-level recursive resolver
├── dns.iter       — iterative resolution helpers
├── dns.dnssec     — DNSSEC validation primitives
├── dns.cache      — TTL-aware cache (in-memory or MNEME)
├── dns.message    — full message encode/decode
├── dns.types      — constants + RDATA codec registry
├── dns.name       — domain name operations
├── dns.wire       — cursor-based wire format reader/writer
└── dns.transport
    ├── .udp       — UDP transport (LEV)
    ├── .tcp       — TCP transport (LEV, 2-byte length prefix)
    └── .tls       — DNS-over-TLS transport (LEV)

dns.message is direction-agnostic — shared between client and future server.