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.
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,
})
| Field | Type | Default | Description |
|---|---|---|---|
servers | {string\|table} | required | Upstream 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) |
timeout | number | 5 | Socket timeout (seconds) |
retries | number | 2 | Retry attempts per query |
use_tcp | boolean | false | Force TCP for all queries |
use_tls | boolean | false | Use DNS-over-TLS (port 853) |
ndots | number | 1 | Search list dot threshold |
search | {string} | {} | Search domain list |
edns_buffer_size | number | 4096 | EDNS advertised UDP payload size |
use_0x20 | boolean | false | 0x20 query name encoding |
cache | table | nil | dns.cache instance for caching |
server_name | string | nil | TLS SNI hostname (TLS only) |
cafile | string | nil | Path to CA certificate bundle (TLS only) |
capath | string | nil | Path to CA certificate directory (TLS only) |
verify | boolean | true | TLS 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: nameserver → servers, search/domain → search,
options ndots:N → ndots, options timeout:N → timeout,
options attempts:N → retries.
-- 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.
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.
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)
}
| Type | Fields |
|---|---|
| A | address — IPv4 string ("93.184.216.34") |
| AAAA | address — IPv6 string ("2606:2800:21f:cb07:6820:80da:af6b:8b2c") |
| CNAME | cname — canonical name |
| NS | nsdname — nameserver FQDN |
| PTR | ptrdname — pointer domain name |
| MX | preference (number), exchange (FQDN) |
| SOA | mname, rname, serial, refresh, retry, expire, minimum |
| TXT | strings (array), text (concatenated string) |
| SRV | priority, weight, port (numbers), target (FQDN) |
| DNSKEY | flags (number), protocol (number), algorithm (number), public_key (binary string) |
| RRSIG | type_covered (number), algorithm (number), labels (number), original_ttl (number), sig_expiration (number), sig_inception (number), key_tag (number), signer_name (FQDN), signature (binary string) |
| DS | key_tag (number), algorithm (number), digest_type (number), digest (binary string) |
| NSEC | next_domain (FQDN), types (set of type numbers, {[1]=true, [28]=true, ...}) |
| NSEC3 | hash_algo (number), flags (number), iterations (number), salt (binary string), next_hashed_owner (binary string), types (set of type numbers) |
| Unknown | raw — raw bytes |
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.
| Field | Default | Description |
|---|---|---|
min_ttl | 30 | Minimum TTL floor (seconds) |
max_ttl | 86400 | Maximum TTL cap (seconds) |
max_negative_ttl | 300 | Negative response TTL cap |
max_entries | 10000 | Max positive cache entries (in-memory) |
max_negative | 5000 | Max negative cache entries (in-memory) |
sweep_interval | 120 | Seconds between expired-entry sweeps (in-memory) |
mneme | false | Enable MNEME backend (opens db at mneme_path) |
mneme_db | nil | Pre-opened MNEME db handle (overrides mneme/mneme_path) |
mneme_path | /var/cache/recall/dns.mneme | MNEME database file path |
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}
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
dig builtindig <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:
--type/-t — record type (default: A)
--nameserver/-n — nameserver IP
--port/-p — nameserver port (default: 53, or 853 for TLS)
--tcp — force TCP transport
--tls — use DNS-over-TLS
--iterative/-i — iterative trace from root servers (like dig +trace)
--dnssec/-d — enable DNSSEC validation (implies -i); each step shows a status badge: [SECURE], [INSECURE], [BOGUS], or [INDETERMINATE]
--timeout — query timeout in seconds (default: 5)
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_SERVERSTable 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:
| Kind | Condition | Info fields |
|---|---|---|
"answer" | Matching records in answer section | records, cname_records, authoritative |
"cname" | CNAME without matching qtype records | target |
"referral" | NS in authority, no answer | zone, ns, glue |
"nxdomain" | RCODE 3 | soa (or nil) |
"nodata" | NOERROR, empty answer, no NS | soa (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.
dns.dnssec provides DNSSEC validation primitives used by iter.trace
and the RECALL resolver.
| ID | Algorithm | Key size |
|---|---|---|
| 8 | RSA/SHA-256 | variable |
| 10 | RSA/SHA-512 | variable |
| 13 | ECDSA P-256/SHA-256 | 64 bytes |
| 14 | ECDSA P-384/SHA-384 | 96 bytes |
| 15 | ED25519 | 32 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.
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).
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
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
Query ID randomization: IDs generated via math.random(0, 65535), never sequential
Response validation: ID match, question section match, source address verification (UDP)
0x20 encoding: optional randomized-case query names for spoofing resistance
DNSSEC: optional chain-of-trust validation from root trust anchor through DS/DNSKEY/RRSIG chains (see above)
Wire format validation: backward-only compression pointers, depth limits, label/name length limits, RDATA bounds checking
Message size limits: 512 bytes UDP (without EDNS) / EDNS-advertised size, 65535 bytes TCP
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.