master
raw

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

  • cache:get(qname, qtype) — returns cached records with adjusted TTLs, or nil

  • cache:put(qname, qtype, records, ttl) — store records with TTL clamping

  • cache:get_negative(qname, qtype) — returns negative cache entry, or nil

  • cache:put_negative(qname, qtype, rcode, soa_record) — store NXDOMAIN/NODATA

  • cache:sweep() — remove expired entries (in-memory: rate-limited by sweep_interval; MNEME: purge_expired)

  • cache:flush() — clear all entries

  • cache:lookup(qname, qtype) — combined positive+negative lookup in a single call

  • cache:stats() — returns {backend, size, neg_size}

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:

  • server / server_name — IP and name of the server queried

  • zone — zone this server is authoritative for

  • qname / qtype — what was queried

  • kind / info — classification (from classify())

  • response — full decoded DNS message

  • rtt_ms — round-trip time in milliseconds

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:

  • dnssec_status"secure", "insecure", "bogus", or "indeterminate"

  • dnssec_detail — human-readable explanation (e.g. "answer validated", "no DS records in referral")

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):

  • Chain-of-trust from IANA root KSK (key tag 20326) through DS → DNSKEY → RRSIG at each delegation

  • Answer RRset RRSIG verified against validated zone keys

  • CNAME RRset RRSIG verified before following; bogus CNAME aborts trace

  • Wildcard expansion detected via RRSIG label count (RFC 4035 §5.3.4)

  • Unsigned answers from signed zones marked "bogus"

Negative responses (NXDOMAIN, NODATA):

  • NSEC zones — full validation: NSEC RRSIGs verified, plus coverage checks proving the queried name doesn't exist (NXDOMAIN) or the queried type doesn't exist for that name (NODATA). NXDOMAIN also verifies no covering wildcard exists. Coverage failure → "bogus".

  • NSEC3 zones — partial validation: SOA RRSIG and NSEC3 RRSIGs are both verified, but NSEC3 hash coverage is not checked. Both must pass for "secure".

  • No denial records — SOA RRSIG only (fallback).

Not implemented

  • NSEC3 hash coverage verification (RFC 5155 iterated hash checks)

  • NSEC3 closest encloser proof via hashed owner names

  • NSEC3PARAM record type (type 51)

  • NSEC3 opt-out flag handling

Module API

  • dnssec.ROOT_DS — IANA root KSK trust anchor (key tag 20326) as a proper RR structure with name, type, class, ttl, rdata fields

  • dnssec.compute_key_tag(rdata_wire) — RFC 4034 Appendix B key tag

  • dnssec.verify_rrsig(rrsig_rr, rrset, dnskey_rr) — verify a single RRSIG against an RRset using the given DNSKEY

  • dnssec.verify_ds(ds_rr, dnskey_rr) — verify a DS digest matches a DNSKEY

  • dnssec.validate_rrset(rrset, rrsig_list, dnskey_list) — find a valid RRSIG chain for an RRset; returns ok, err, had_unsupported

  • dnssec.validate_dnskey_rrset(dnskey_records, dnskey_rrsigs, ds_records) — full DS → KSK → zone DNSKEY validation; returns ok, err, had_unsupported

  • dnssec.validate_nsec_nodata(nsec_records, qname, qtype) — verify NSEC records prove NODATA (name exists, type absent); returns ok, err

  • dnssec.validate_nsec_nxdomain(nsec_records, qname) — verify NSEC records prove NXDOMAIN (name covered by range, wildcard covered); returns ok, err

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.