# 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.

```lua
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

| 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`.

```lua
-- 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.

```lua
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:

```lua
{
    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

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

## Cache

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

```lua
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

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

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

## 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).

```lua
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`.

```lua
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

```lua
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](https://git.lilush.link/lilpacks/recall) resolver.

### Supported algorithms

| 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.

### 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

- **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

## 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.
