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

1. `RECALL_CONFIG_FILE` environment variable, if set
2. `/etc/recall/config.json` otherwise

### 1.2 Minimal config

```json
{
  "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):

```json
{
  "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

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

```json
{
  "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:

```json
{
  "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) |

```json
{
  "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 |

```json
{
  "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:

1. Decode failure → silently dropped (no response)
2. Not a query (`QR=1`) → silently dropped
3. Non-standard opcode (`OPCODE != 0`) → NOTIMP
4. EDNS version > 0 → BADVERS (extended rcode via OPT record)
5. Empty question section → FORMERR
6. Non-IN class (`qclass != 1`) → REFUSED
7. Invalid domain name → FORMERR

### 4.3 Query routing

Queries that pass validation are routed through resolution paths in order:

1. **Scripted subdomains** — if qname matches a configured scripting subdomain
2. **Forward zones** — if qname matches a configured forward zone (longest match)
3. **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 servers
4. **Iterative 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:

1. Check positive cache → return on hit
2. Check negative cache → return NXDOMAIN/NODATA on hit
3. Call `iter.trace(qname, qtype)` → walk from root servers to authoritative answer
4. Process 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", soa`
   - **nodata**: cache negative with SOA, return `nil, "NODATA", soa`
   - **error**: 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)
- `RD` echoed from query
- Question 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`.

```bash
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`, `error`
- `string` — `byte`, `char`, `find`, `format`, `gmatch`, `gsub`, `len`, `lower`, `match`, `rep`, `sub`, `upper`
- `table` — `concat`, `insert`, `remove`, `sort`
- `math` — `abs`, `ceil`, `floor`, `max`, `min`, `random`
- `os` — `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.

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

```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) }
```

**Wildcard subdomain routing:**

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

```lua
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 port
- `listener spawned` — manager confirms listener process started
- `listener crashed` / `listener exited with error` — listener process failures
- `listener spawn failed` — fork failure
- `UDP bind failed` / `TCP bind failed` — socket bind errors (error level)
- Forward zone client creation failure — logged as warning when a zone's `dns.client` fails to initialize
- Resolution trace — debug-level events for each resolved query, including qname, qtype, trace length, CNAME chain presence, and step-by-step server path
