RECALL Operations and Development Guide
Overview
RECALL is a recursive caching DNS resolver: iterative resolution from the IANA root servers, forward zones, dynamic Lua scripting, DNSSEC validation, and a persistent MNEME cache. It runs as a manager process with UDP+TCP listeners and coroutine-based concurrency on the LEV async I/O runtime.
1. Quick Start
1.1 Config file location
RECALL reads config JSON from:
RECALL_CONFIG_FILEenvironment variable, if set/etc/recall/config.jsonotherwise
1.2 Minimal config
{
"ip": "127.0.0.1",
"port": 5353
}
This starts RECALL on port 5353, performing full iterative resolution from IANA root servers on cache misses and caching results in memory.
To enable persistent caching with MNEME (embedded database):
{
"ip": "127.0.0.1",
"port": 5353,
"cache": { "mneme": true }
}
The MNEME database file defaults to /var/cache/recall/dns.mneme.
1.3 Test query
dig @127.0.0.1 -p 5353 example.com A
2. Full Configuration Reference
All defaults are defined in recall.lua.
2.1 Top-level config
| Key | Default | Notes |
|---|---|---|
ip | 127.0.0.1 | Bind address |
port | 53 | Bind port (UDP and TCP) |
timeout | 5 | Per-query socket timeout for iterative resolution (seconds) |
retries | 2 | Default retry attempts for forward-zone upstreams (per-zone overridable) |
edns_buffer_size | 4096 | EDNS advertised UDP payload size |
tcp_timeout | 10 | TCP session idle timeout (seconds) |
max_tcp_connections | 100 | Max concurrent TCP connections (global) |
max_tcp_per_ip | 10 | Max concurrent TCP connections per source IP |
max_udp_size | 4096 | Max UDP datagram size to receive |
log_level | info | Logger level passed to std.logger |
dnssec | false | Enable DNSSEC validation for iterative resolution |
dnssec_reject_bogus | false | Return SERVFAIL for DNSSEC-bogus responses (requires dnssec) |
2.2 Cache config (cache)
| Key | Default | Notes |
|---|---|---|
min_ttl | 30 | Minimum TTL floor (seconds) |
max_ttl | 86400 | Maximum TTL cap (seconds) |
max_negative_ttl | 300 | Negative response (NXDOMAIN/NODATA) TTL cap |
mneme | false | Use embedded MNEME database for persistent caching |
mneme_path | /var/cache/recall/dns.mneme | MNEME database file path |
compact_threshold | 0.5 | Free-page ratio that triggers auto-compaction |
compact_interval | 3600 | Minimum seconds between compaction checks |
Cache config is passed to dns.cache.new(). Backend priority: MNEME (if mneme = true) > in-memory.
2.3 Forward zones config (forward_zones)
Map of zone names to zone configuration objects:
{
"forward_zones": {
"internal.corp.": {
"servers": ["10.0.0.10", "10.0.0.11"],
"timeout": 3
},
"dev.example.com.": {
"servers": ["10.120.0.10:5353"],
"timeout": 2,
"retries": 3
},
"secure.corp.": {
"servers": ["1.1.1.1", "1.0.0.1"],
"use_tls": true,
"server_name": "cloudflare-dns.com"
}
}
}
Per-zone fields:
| Key | Default | Notes |
|---|---|---|
servers | required | Upstream nameserver list |
timeout | top-level timeout | Per-query timeout for this zone |
retries | 2 | Retry attempts |
use_tls | false | Use DNS-over-TLS (port 853) for this zone |
server_name | server address | TLS SNI hostname |
cafile | system CA bundle | Path to CA certificate bundle |
capath | — | Path to CA certificate directory |
verify | true | Enable TLS certificate verification |
Server entries support port specification as "host:port" strings or {"host": "10.120.0.10", "port": 5353} tables. When use_tls is enabled, servers default to port 853 instead of 53.
Forward zone queries use dns.client with RD=1 (recursive requests to the configured forwarders), not iterative resolution. Each zone gets its own dns.client instance sharing the global cache. Zone matching is longest-match: if both corp. and dev.corp. are configured, queries for app.dev.corp. route to the dev.corp. forwarder.
Root zone forwarding
The root zone "." can be used as a catch-all forward zone to route all queries to specific upstream resolvers, completely replacing iterative resolution:
{
"forward_zones": {
".": {
"servers": ["1.1.1.1", "1.0.0.1"],
"use_tls": true,
"server_name": "cloudflare-dns.com"
}
}
}
When "." is configured, all queries that don't match a more specific forward zone are sent to its servers. Forward zone matches are terminal — if the upstream forwarder fails, SERVFAIL is returned without falling back to iterative resolution. More specific zones still take priority due to longest-match-first ordering.
2.4 Scripting config (scripting)
| Key | Default | Notes |
|---|---|---|
enabled | false | Enable script engine |
scripts_dir | /etc/recall/scripts | Directory holding script files |
subdomains | [] | List of subdomains with dynamic scripts |
cache_ttl | 60 | TTL for caching script-generated records |
script_cache_ttl | 300 | In-memory compiled function cache TTL (seconds) |
{
"scripting": {
"enabled": true,
"scripts_dir": "/etc/recall/scripts",
"subdomains": ["dyn.example.com."],
"cache_ttl": 60,
"script_cache_ttl": 300
}
}
Scripting is independent of the cache backend — it works with both the in-memory cache and MNEME.
See section 7 for the scripting API.
2.5 Rate limit config (rate_limit)
| Key | Default | Notes |
|---|---|---|
enabled | true | Enable UDP rate limiting |
queries_per_second | 50 | Token refill rate per source IP |
burst | 100 | Token bucket capacity (max burst) |
cleanup_interval | 15 | Stale bucket GC interval (seconds) |
max_buckets | 50000 | Maximum tracked source IPs; new IPs rejected when full |
{
"rate_limit": {
"enabled": true,
"queries_per_second": 50,
"burst": 100,
"cleanup_interval": 15,
"max_buckets": 50000
}
}
The per-IP token bucket governs UDP queries and per-query throughput on established TCP connections. TCP connections are additionally limited structurally by max_tcp_connections (global) and max_tcp_per_ip (per source IP). See section 5 for details.
3. MNEME Storage
When cache.mneme is enabled, RECALL stores the DNS cache in a single MNEME database file (default /var/cache/recall/dns.mneme) under the cache keyspace:
| Keyspace | Key format | Value | TTL |
|---|---|---|---|
cache | <qname>:<qtype> | JSON {records: [...]} | DNS record TTL (clamped) |
cache | NEG:<qname>:<qtype> | JSON {rcode, soa} | Negative TTL from SOA |
Auto-compaction
MNEME's copy-on-write B-tree accumulates free pages as DNS records expire and get replaced. RECALL periodically checks the free-page ratio (free_pages / page_count) and compacts when it exceeds compact_threshold (default 0.5). Compaction rewrites the database to a new file, reclaiming space, then atomically replaces the original. The minimum interval between compaction checks is compact_interval seconds (default 3600).
4. Architecture
recall (manager)
└── recall.listener — LEV async event loop (epoll + coroutines)
├── recall.handler — request pipeline per query
│ ├── recall.scripting — dynamic script execution
│ ├── recall.forward — forward zone routing
│ └── recall.resolver — iterative resolution (dns.iter + dns.cache)
├── [UDP coroutines] — one per incoming query
├── [TCP coroutines] — one per TCP connection
└── dns.cache — TTL cache (in-memory or MNEME)
4.1 Process model
Manager forks a single listener process. The listener runs inside lev.run(), using epoll-based async I/O with Lua coroutines for concurrency. UDP queries are received in a main loop and each query spawns a detached coroutine for processing. TCP connections are accepted in a dedicated coroutine and each accepted connection spawns its own handler coroutine. All upstream DNS queries (iterative resolution, forward zones) use LEV transports (dns.transport.udp, dns.transport.tcp). MNEME caching is embedded — no network I/O for cache operations.
4.2 Query validation
The handler validates each incoming query before resolution. Checks run in order:
Decode failure → silently dropped (no response)
Not a query (
QR=1) → silently droppedNon-standard opcode (
OPCODE != 0) → NOTIMPEDNS version > 0 → BADVERS (extended rcode via OPT record)
Empty question section → FORMERR
Non-IN class (
qclass != 1) → REFUSEDInvalid domain name → FORMERR
4.3 Query routing
Queries that pass validation are routed through resolution paths in order:
Scripted subdomains — if qname matches a configured scripting subdomain
Forward zones — if qname matches a configured forward zone (longest match)
RFC1918 reverse zone guard — if qname falls under a private reverse zone (
10.in-addr.arpa.,168.192.in-addr.arpa.,16-31.172.in-addr.arpa.), return NXDOMAIN with a synthetic SOA immediately instead of querying public root serversIterative resolution — full walk from IANA root servers via
dns.iter.trace()
The first path that produces a result (or a definitive negative like NXDOMAIN) is used. Scripted responses that return nil fall through to forward zones, then to the RFC1918 guard, then to iterative resolution. To resolve private reverse lookups against an internal DNS server, configure a forward zone for the relevant .in-addr.arpa. zone — forward zones (step 2) take priority over the guard (step 3).
5. Rate Limiting
5.1 UDP rate limiting
RECALL uses a token bucket algorithm to rate-limit UDP queries per source IP. Each source IP gets an independent bucket with a configurable refill rate (queries_per_second, default 50) and capacity (burst, default 100). Packets that arrive when the bucket is empty are silently dropped — no REFUSED or SERVFAIL response is sent.
Rate limiting is checked before decoding the DNS message, minimizing CPU cost for dropped packets.
Stale buckets (idle longer than burst / queries_per_second seconds) are garbage-collected every cleanup_interval seconds (default 15).
The same per-IP token bucket is also consulted for each query received on an established TCP connection; a query that finds the bucket empty terminates the connection.
5.2 TCP connection limits
TCP connections are structurally limited (independently of the token bucket):
Global limit (
max_tcp_connections, default 100): maximum total concurrent TCP connections. New connections are closed immediately when at capacity.Per-IP limit (
max_tcp_per_ip, default 10): maximum concurrent TCP connections from a single source IP. New connections from IPs at the limit are closed immediately.
6. Resolution Semantics
6.1 Iterative resolver (recall.resolver)
The resolver wraps dns.iter.trace() with dns.cache for positive and negative caching.
Resolution flow:
Check positive cache → return on hit
Check negative cache → return NXDOMAIN/NODATA on hit
Call
iter.trace(qname, qtype)→ walk from root servers to authoritative answerProcess trace result:
answer: collect CNAME chain from intermediate steps, prepend to answer records, cache combined result, return
nxdomain: cache negative with SOA, return
nil, "NXDOMAIN", soanodata: cache negative with SOA, return
nil, "NODATA", soaerror: return
nil, error_description
CNAME chain handling: when iter.trace() follows CNAMEs (restarting from root for each target), the resolver collects CNAME records from intermediate trace steps and prepends them to the final answer. Clients receive the full CNAME chain in the answer section, matching standard resolver behavior.
6.2 DNSSEC validation
When dnssec is enabled, the iterative resolver performs chain-of-trust validation from the IANA root trust anchor through each delegation to the authoritative answer. At each step, DS records from referrals are collected, DNSKEY records are fetched from zone apex servers, and RRSIG signatures are verified cryptographically.
Each resolution step receives a validation status:
| Status | Meaning |
|---|---|
secure | Chain of trust validated from root to answer |
insecure | Zone is provably unsigned (no DS in parent delegation) |
bogus | Signatures present but verification failed |
indeterminate | Cannot determine (unsupported algorithm, missing data) |
Supported DNSSEC algorithms: RSA/SHA-256 (8), RSA/SHA-512 (10), ECDSA P-256 (13), ECDSA P-384 (14), ED25519 (15).
When dnssec_reject_bogus is also enabled, queries that resolve with a bogus DNSSEC status return SERVFAIL instead of the answer. This prevents clients from receiving responses that fail signature verification. Unsigned zones (insecure) are passed through normally.
DNSSEC validation adds one extra DNSKEY query per delegation level (root, TLD, authoritative zone). The validation status is logged at debug level (or warn level for bogus results).
6.3 Forward zone resolution (recall.forward)
Forward zones use dns.client instances configured with the zone's upstream servers. Queries are sent with RD=1 (recursion desired). The dns.client provides retry, failover, CNAME following, and integrated caching through the shared cache instance.
Zone matching is longest-match by label count. A query for app.dev.corp. matches dev.corp. before corp. if both are configured.
6.4 Response construction
All responses set:
QR=1(response),RA=1(recursion available),AA=0(not authoritative)RDechoed from queryQuestion section echoed from query
EDNS OPT echoed when client sent EDNS
Response types:
| Condition | RCODE | Answer | Authority |
|---|---|---|---|
| Records found | NOERROR | records | empty |
| Name not found | NXDOMAIN | empty | SOA if available |
| No records for type | NOERROR | empty | SOA if available |
| Resolution failure | SERVFAIL | empty | empty |
| Malformed query | FORMERR | empty | empty |
6.5 UDP truncation
When a UDP response exceeds the client's advertised EDNS buffer size (or 512 bytes without EDNS), the response is re-encoded with TC=1 set and the answer, authority, and additional sections stripped. The client is expected to retry over TCP.
7. Scripting API
Dynamic Lua scripts generate DNS responses at query time. Scripts are loaded from
.lua files on disk and are independent of the cache backend — scripting works
with both the in-memory cache and MNEME.
7.1 Storing scripts
Scripts live in scripting.scripts_dir (default /etc/recall/scripts). Each
configured subdomain loads from <scripts_dir>/<subdomain>.lua, where
<subdomain> is the normalized name with its trailing dot stripped. For example,
the subdomain dyn.example.com. loads from /etc/recall/scripts/dyn.example.com.lua.
mkdir -p /etc/recall/scripts
cat << EOF > /etc/recall/scripts/dyn.example.com.lua
if qtype ~= dns.TYPE.A then return nil end
local hosts = { "10.0.1.1", "10.0.1.2", "10.0.1.3" }
local idx = (os.time() % #hosts) + 1
return { dns.a_record(qname, hosts[idx], 30) }
EOF
7.2 Script environment
Scripts execute in a sandboxed environment with setfenv. Available globals:
Query context:
| Variable | Type | Description |
|---|---|---|
qname | string | Normalized FQDN with trailing dot |
qtype | number | Numeric type code |
DNS record constructors:
| Function | Arguments | Record type |
|---|---|---|
dns.a_record(name, address, ttl) | name, IPv4 string, TTL | A |
dns.aaaa_record(name, address, ttl) | name, IPv6 string, TTL | AAAA |
dns.cname_record(name, target, ttl) | name, target FQDN, TTL | CNAME |
dns.txt_record(name, text, ttl) | name, text string, TTL | TXT |
dns.mx_record(name, exchange, preference, ttl) | name, exchange FQDN, pref, TTL | MX |
dns.srv_record(name, target, port, priority, weight, ttl) | name, target FQDN, port, priority, weight, TTL | SRV |
dns.ns_record(name, nsdname, ttl) | name, NS FQDN, TTL | NS |
dns.ptr_record(name, ptrdname, ttl) | name, PTR FQDN, TTL | PTR |
All TTL arguments default to 60 seconds when omitted. The dns.TYPE table is available for type comparisons (dns.TYPE.A, dns.TYPE.AAAA, etc.).
Safe builtins:
type,tostring,tonumber,pairs,ipairs,select,unpack,pcall,errorstring—byte,char,find,format,gmatch,gsub,len,lower,match,rep,sub,uppertable—concat,insert,remove,sortmath—abs,ceil,floor,max,min,randomos—time,clock
Not available: io, os.execute, require, dofile, loadfile, loadstring, debug, coroutine.
7.3 Script return value
Scripts must return an array of record tables (as produced by the dns.*_record() constructors), or nil to fall through to forward zone / iterative resolution.
-- Return A records
return { dns.a_record(qname, "10.0.1.1", 60) }
-- Return nil to fall through
if qtype ~= dns.TYPE.A then return nil end
7.4 Script caching
Compiled script functions are cached in memory for script_cache_ttl seconds (default 300). After expiry, the script source is re-read from its file on disk and recompiled. This allows hot-reloading scripts without restarting RECALL.
Script-generated records are cached in the DNS cache for cache_ttl seconds (default 60).
7.5 Example scripts
Round-robin A records:
if qtype ~= dns.TYPE.A then return nil end
local hosts = { "10.0.1.1", "10.0.1.2", "10.0.1.3" }
local idx = (os.time() % #hosts) + 1
return { dns.a_record(qname, hosts[idx], 30) }
Wildcard subdomain routing:
if qtype ~= dns.TYPE.A then return nil end
local label = qname:match("^([^.]+)%.")
if label == "web" then
return { dns.a_record(qname, "10.0.2.1", 60) }
elseif label == "api" then
return { dns.a_record(qname, "10.0.2.2", 60) }
end
return nil
TXT record with timestamp:
if qtype ~= dns.TYPE.TXT then return nil end
return { dns.txt_record(qname, "generated at " .. os.time(), 10) }
8. Failure-Mode Responses
| RCODE | Trigger |
|---|---|
| FORMERR | Missing question section, invalid domain name |
| SERVFAIL | Iterative resolution failure, forward zone timeout, DNSSEC bogus (when dnssec_reject_bogus enabled) |
| NXDOMAIN | Domain does not exist (from authoritative server or cache), or reverse lookup for RFC1918 private network with no forward zone configured |
| NOERROR (empty) | Name exists but no records of requested type (NODATA) |
| NOTIMP | Non-standard opcode (OPCODE != 0) |
| BADVERS | EDNS version > 0 (extended rcode via OPT record) |
| REFUSED | Query class is not IN (1) |
Non-query messages (QR=1) are silently dropped. Malformed UDP packets that fail to decode are silently dropped.
9. Observability
RECALL uses structured logging via std.logger. Log events include:
RECALL listening— listener startup with bind IP and portlistener spawned— manager confirms listener process startedlistener crashed/listener exited with error— listener process failureslistener spawn failed— fork failureUDP bind failed/TCP bind failed— socket bind errors (error level)Forward zone client creation failure — logged as warning when a zone's
dns.clientfails to initializeResolution trace — debug-level events for each resolved query, including qname, qtype, trace length, CNAME chain presence, and step-by-step server path