ironroot-resolver
Use for the Ironroot DNSSEC resolver in DNS-Resolver.
Source facts
- Repository
- LoopyLuci/Skills
- Last source activity
- October 3, 2026 at 21:16
- Detected SKILL.md language
- English
- Stars
- 1
- Forks
- 1
Install options
The review-first prompt is selected by default. You can switch to a direct command or download a local copy.
Review the source files
Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.
Showing SKILL.md
SKILL.md
Source instructions · Read-only preview- name
- ironroot-resolver
- description
- Use for the Ironroot DNSSEC resolver in DNS-Resolver.
- version
- 1
- license
- MIT
- metadata
- {"hermes":{"tags":["dns","resolver","dnssec","bpf","xdp","rust"],"related_skills":[]}}
# Ironroot resolver
Recursive DNSSEC-validating resolver. Workspace `ironroot/`: `ironroot` (userspace),
`ironroot-common` (no_std shared parser), `ironroot-ebpf` (XDP L0). Spec `IDEA.md.txt`.
## When to use
Anything touching this resolver: the pipeline, the parsers, the caches, the DNSSEC
validator, or the XDP fast path. Read the trap sections before writing a test --
most of the cost here is a wrong assumption about an API, not a hard bug.
## Running it
- Bind flag is `--listen <SocketAddr>`, NOT `--bind`. Default `127.0.0.1:5353` collides
with Windows mDNS, so use a high port (e.g. `--listen 127.0.0.1:15410`).
- `--no-validate` exists only with `--features diagnostics-no-validate`. Without it the
field is not compiled in, so a production binary has no bypass to invoke.
- `--trust-anchor <path>` points at the RFC 5011 anchor file.
Differential suite (network-dependent):
IRONROOT_UNDER_TEST=127.0.0.1:15411 IRONROOT_REFERENCE=8.8.8.8 \
cargo test -p ironroot --test differential -- --ignored --test-threads=1
## Tests and gates
| Suite | Count | Network |
|---|---|---|
| unit | 106 | no |
| conformance | 49 | no |
| properties (proptest) | 16 | no |
| concurrency | 9 | partly |
| hermetic e2e | 4 + 10 ignored | no |
| common | 39 | no |
| differential | 9 | yes |
`scripts/verify.sh` runs 11 gates, logs each to `logs/`, prints a summary.
`--quick`, `--only <gate>`, `--list`. Treats a compiler *warning* as a failure:
`#![allow(dead_code)]` hid a real bug here.
`scripts/diagnose.{sh,py}` queries a live resolver and prints a full decode -- flags,
counts, a hand-rolled record walk reporting where a bad message stopped parsing, and a
TCP second opinion.
## Remote
Private repo: https://github.com/LoopyLuci/ironroot -- branch `main`, tag `v0.1.0`.
Four CI jobs: userspace tests, hermetic e2e, the validation-bypass binary grep, and
the TLC model check. The bypass grep is mutation-tested: it fails against a
`diagnostics-no-validate` build and passes against a secure one. `tla2tools.jar` is
fetched by `scripts/fetch-tla.sh`, not committed.
## Measure the running resolver; do not read it
Reading the code found nothing. Timing it found three bugs in one pass:
- `h0.debian.org` took **14 s**, with a visible 10 s gap between two hops. The
per-attempt timeout doubled per retry (RFC 1035 §4.2.2) with no total cap, so
one dead server cost 1.5 + 3 + 6 s. Now bounded by `max_total_per_server`.
- `--upstream-timeout-ms` reached only the validator, so it had no effect on the
iterator -- the part that waits most. It now lives on `UpstreamConfig`.
- UDP datagrams were **dropped by the kernel** under burst, because the socket
used the OS default buffer. A dropped query is indistinguishable from being
down. Buffers now requested at 4 MiB; the kernel grants 8.
Probed and found correct, so left alone: TCP, 14 hostile datagrams, flag hygiene
across TC/opcode/AA/CD/RD/Z, and the rate limiter (207/193 at defaults).
### Two measurement errors that first looked like resolver bugs
Both are worth remembering because each reads as a server fault:
- **Firing 400 datagrams in a tight loop tests the socket buffer, not the
limiter.** The tokio task never gets scheduled, so the buffer absorbs them and
the limiter sees nothing. Pace the burst or yield between sends.
- **A UDP socket reused after a timeout reports a spurious ICMP reset on
Windows.** Zero resets on a fresh socket for the same queries. Use one socket
per query when measuring.
### `#[cfg(unix)]` code that only ever ran on Linux
Socket buffer tuning took three attempts that all compiled and passed on Windows
while panicking every runtime worker on Linux, because the block was `cfg(unix)`
and never executed there. `UdpSocket::from_std` registers a *blocking* socket,
which tokio permits only where blocking is legal, and an `async fn` body never is
-- on any platform. Tuning belongs in `main`, before the runtime exists, where it
is merely a syscall.
The general rule: anything behind a platform cfg has never been executed unless it
was run on that platform. `cargo build` on Windows is not a test of it.
- **DO bit read from the OPT rclass** instead of bit 15 of the OPT TTL (RFC 6891
§6.1.2). The XDP path did this, so the L0 key never matched a DO-bearing client:
a silent 100% fast-path miss. Userspace had it right; pin the two together.
- **Rate limiter after the cache.** The limiter is the pipeline's first stage, but
the L1 lookup ran *before* the pipeline, so cache hits skipped it and a client
asking for one popular cached name got unlimited service. `Kernel` now holds the
limiter and checks it before the cache.
- **`cacheable` accepted TTL 0** (RFC 2181 §5.2). Storing it is a correctness bug.
- **Referrals were cacheable and read as negative answers.** NOERROR-with-no-answers
is both a referral's shape and NODATA's. Now `cacheable` requires `ancount > 0`
and `is_negative` requires an SOA (RFC 2308 §2.1).
- **The validator built a fresh transport per request**, discarding
`--upstream-timeout-ms`. Found only after the lib/bin split removed
`#![allow(dead_code)]`.
## Sweep the corpus; don't assert a hand-written list
`ironroot/src/bin/differential-corpus.rs` compares 121 questions against a reference
and reports **every** disagreement. Run it before concluding a resolver behaves.
Five comparison traps, each producing false positives before being fixed:
- **Never compare AD with the reference.** A validating resolver is *required* to set
AD (RFC 4035 3.2.3) and public resolvers do not echo it. Only "never AD on a
failure" is a real invariant.
- **Record contents are not comparable.** Anycast and round-robin return a different
address subset per query -- `reddit.com` gave five addresses at the reference, two
here, both correct. Compare **names and types**.
- **The CNAME chain below the queried name may differ.** The reference chased Fastly,
we chased Akamai. Compare only records owned by the queried name.
- **A cold cache exceeds the per-query timeout.** Retry once; report as latency, not
correctness.
- **A public reference throttles a fast loop.** Pace it, and stop after three
consecutive reference misses instead of reporting the rest as failures.
## The harness must not measure itself
Three separate defects all had the same shape: the gate or sweep reported its own
limits as resolver failures, and each read convincingly like a resolver bug.
- **The corpus sweep tripped the resolver's own rate limiter.** 121 questions in quick
succession from one client address exceed the default 100 qps / 200 burst, and the
rejections were counted as timeouts: 66/121 agreed, with 54 "no bytes back". The
resolver was throttling the test, not failing. Raise the limits for the harness
resolver only; production defaults are correct and stay tested.
- **The sweep's 8s per-query timeout was measuring its own pacing**, not resolution
time. Four names timed out that resolve in 0.1-1.3s standalone. At 20s: 121/121.
- **`cargo clean` never ran** because a bare `cargo` does not exist on this host, only
`cargo.exe`, so the gate meant to prevent inspecting a stale binary inspected a stale
binary.
Before believing a "resolver failure", ask what the harness was doing when it happened.
A timeout and a rejection are both harness-shaped.
## A Windows socket error is not a fatal one
`recv_from(&mut buf).await?` in the UDP accept loop removed the listener on the first
recoverable error. Windows reports `WSAECONNRESET` (10054) on `recv_from` after an ICMP
port-unreachable from an earlier send -- routine, and caused by any client that
vanishes between our reply and its retransmit.
The symptom is deceptive: the process is alive and still holds the socket, so health
checks pass, but nothing is answered any more. It showed up as the corpus sweep
agreeing on 80 of 121 questions and then reporting "no bytes back" for every
remainder -- which reads as rate limiting, not a dead listener.
Count transient errors and continue; log the first and every hundredth so a persistent
fault stays visible. Give up on a bound number of consecutive errors rather than
spinning forever. `ConnectionReset`, `Interrupted`, `WouldBlock` and `TimedOut` are all
recoverable; anything else is a genuine fault.
## BPF: the verifier tests the object, not the loader
`verify-bpf.sh` rebuilds the BPF *object* from the Windows tree every run. The
*loader* that actually executes it lives inside the initramfs at
`/home/ironroot/vm/root_base/loader` and is **not** rebuilt. Days of edits to
`vm/load.c` were silently untested, and the gate kept reporting the same error the
whole time. After touching `load.c`:
```
cp vm/load.c ~/vm/ && cd ~/vm && gcc -O2 -static -o load load.c && cp load root_base/loader
```
Three bugs lived in that untested file. A `call` immediate is a **signed instruction
distance from the calling instruction**, not a byte offset and not a file offset — the
old code wrote `symbol value + section file offset`, which is short by the program's own
start offset and puts `.text.unlikely.` outside the program entirely
("call to invalid destination"). Code in another section must be **copied into** the
program array, since a BPF program is one contiguous block. And `insn_cnt` has to count
the appended code, or the destinations exist outside the submitted range and produce the
*same* error — which is what made the second bug hard to spot.
Always print the value actually submitted, not the variable that happens to be nearby.
A diagnostic reading a stale variable is worse than none: it reports a plausible number
and hides the effect of the change.
## Never test against a stale binary
A temporary debug line failed to compile, so the "fix worked" run actually exercised
the previous artifact and reported a pass. `cargo build` returning non-zero is not an
optional detail: check the exit status before believing any result from a rebuilt
binary, and prefer a mutation that must *fail* to a test that must pass -- a green test
that never ran proves nothing.
The same trap when a mutation silently does not apply: assert the mutated text differs
from the original before running anything, or the "detected" result is fiction.
## Gate wiring: a gate that cannot run must say so
The corpus sweep was added to the `GATES` array but never dispatched. It printed
`unknown gate: corpus` and the run still reported "all gates passed". A gate that
cannot run must fail, not skip quietly -- a silent skip is a green tick it did not earn.
Related, all found the hard way: `A=x B=y` on one line is a single command taking `A` as
its name, so **neither** variable is set. `--bin foo` builds only `foo`, leaving
sibling binaries absent so later checks run against a missing file -- and a missing
executable reads as a *passing* security check. `[ -f "X:/..." ]` is always false from a
POSIX shell, so Windows-form paths silently miss files that exist; and guessing the
POSIX form (`/X/...` rather than MSYS's `/mnt/x/...`) is worse than not converting,
because an unverified guess looks like a real path.
## DNSSEC verdicts: absence of evidence is not forgery
The two worst bugs were verdict-mapping errors, in **different code paths**:
| Situation | Correct | Was |
|---|---|---|
| Unsigned zone, answer has no RRSIG | Insecure | `Err` -> SERVFAIL |
| Unsigned zone, NODATA with no SOA | Insecure | `Ok(Some(Bogus))` -> SERVFAIL |
`Missing SOA record for NODATA` is only a violation in a **signed** zone. In an
unsigned zone the server is not obliged to send one. `github.com` publishes no DS, so
both its plain answers and its NODATA answers were refused -- one of the most-visited
names on the Internet.
The guard stays narrow: **zero RRSIG records anywhere** means Insecure. Anything
carrying signatures that fails to parse still fails closed. Fix one arm and check the
other -- the first fix did not cover the second.
**Mutation-test every verdict fix.** Reverting the Bogus-path fix left the suite green
until unsigned-NODATA coverage existed.
### Distinguish a broken zone from a resolver bug
A sweep flagged `go.dns.pl` as SERVFAIL. Chasing it: `dns.pl` publishes a DS but
serves no DNSKEY -- an unsigned island -- and Google and Cloudflare SERVFAIL on it
too. Ironroot answers `dns.pl` with **AD=1**, more strictly than either. Broken zone,
not a bug; SERVFAIL was right. Check whether independent references fail the same way
before "fixing" anything.
## Two gaps found by probing the header, not the code
Reading the resolver found nothing in these two places; a sweep of header fields
did. Both produced a **correct-looking response with the AD bit set**, which is
the worst kind of bug for a validating resolver: the answer looked verified.
**No opcode validation existed at all.** Every opcode was resolved as QUERY:
```
sent opcode=2 (STATUS) -> rcode=0, 701-byte answer, AD=1
sent opcode=15 -> rcode=0, 701-byte answer, AD=1
```
UPDATE is the dangerous case: a client could believe a record had been changed.
RFC 1035 4.1.1 requires NOTIMP, with the opcode **echoed** so the client can tell
which outstanding request was rejected.
**QCLASS was mixed into the cache key but never validated.** So CHAOS was
forwarded upstream as IN:
```
version.bind class=3 (CHAOS) -> rcode=0, 1031 bytes, 6 authority records, AD=1
example.com class=3 -> the validated IN answer, verbatim
```
A recursive server answers only for IN. The lesson generalises: **a field being
present in a hash key is not the same as it being checked.**
## Unit tests cannot prove enforcement
The first opcode test called `build_response` directly. It passed. Removing the
*entire* kernel check that enforces NOTIMP left it **green**, because
`build_response` never enforced anything -- it just formats. Mutation testing
caught this immediately.
Rules that follow:
- A test of a *formatter* is not a test of a *decision*. Drive the kernel.
- If mutating the code under test does not fail the test, the test is decorative.
- **Do not truncate the captured output before deciding.** `cargo test ... | tail -5`
cut off the `test result: FAILED` line, so three real mutations read as "not detected"
and three load-bearing links looked untested. They were all detected. Capture the whole
run, or grep for the marker rather than reading the tail.
- Check first whether the enforcement already existed elsewhere. The QR-set check
was already in `Query::parse` (`NotAQuery`), so a kernel check for it was dead
code; and asserting on the kernel instead of the parser would have passed for
the wrong reason.
- When a test fails with a confusing number, suspect the test. `flags >> 11`
yielded `17` (QR|rcode), not the opcode; the fix was masking `0x7800`.
## Never send more than the client asked for (RFC 6891 6.2.3)
There was no truncation at all. A client advertising 512 bytes got 1319 with TC
clear, and got the same 1319 whatever it asked for.
- Drop records **whole**, in section order, stopping at the first that does not fit.
Keeping a later, smaller record in the gap leaves contents that do not match the
counts.
- Never drop the question: a response whose question does not match is discarded
(RFC 1035 4.1.1), turning a working answer into silence.
- **Cache the complete answer; fit a copy per client on the way out.** Caching the
truncated bytes made every later client receive a partial response.
- The **cache-hit path must truncate too.** Returning cached bytes verbatim is the
common case and no cold-cache test observes it.
### TC is bit 9 of the flags word -- byte **2**
```rust
out[2] |= 0x02; // correct: TC
out[3] |= 0x02; // WRONG: sets bit 1 of the low byte, inside the 4-bit RCODE
```
The wrong version turns every NOERROR into SERVFAIL, and only for responses large
enough to truncate -- so small fixtures pass and live traffic fails.
### TC cannot be derived from the final size
A truncated response *shrinks to fit* and still carries TC. So `len(msg) <= limit`
says nothing about whether TC should be set. Two of my own assertions were wrong
before the code was. Compare against the **complete** answer's size instead.
## Measuring: fresh process per configuration
Reusing one process lets the first request warm the cache, after which every later
configuration tests the cache-hit path. Three wiring mutations passed this way.
Each size needs its own process, or its own name.
To prove a mutation is load-bearing, measure directly rather than trusting a test:
```
correct code : cached@512 -> len=456 tc=1 OK
cache-hit bypassed: cached@512 -> len=1319 tc=0 VIOLATION
```
### Two self-inflicted errors worth remembering
- `cargo fmt` reflowed `<< 16` into a multi-line form; a later `str::replace`
silently matched nothing, so the change appeared not to take effect. **Assert the
replacement applied.**
- Moving new functions to sit before `mod tests` deleted the entire test module. When
reordering a file, restore from git and diff.
## Header fields that were never validated
Four fields the pipeline either ignored or checked only incidentally. All four
produced a correct-looking response with **AD=1**, which is the worst shape for a
validating resolver. Found by sweeping header fields against the running binary,
never by reading code:
| Field | Was | Now |
|---|---|---|
| OPCODE | every opcode resolved as QUERY | NOTIMP, opcode echoed |
| QCLASS | mixed into the cache key, never checked | REFUSED unless IN |
| EDNS version | read out of the OPT TTL and discarded | BADVERS |
| AXFR / IXFR | resolved as lookups -> SERVFAIL | REFUSED |
SENTFAIL vs REFUSED matters for transfers: SERVFAIL means "retry later", so a
transfer client retries forever against a server that will never comply.
## RFC 6891 OPT TTL layout
Counting from the **most significant** bit:
```
|EXTENDED RCODE (8)| VERSION (8) |DO|Z| RCODE (4)|
```
So the extended rcode is bits 31..24 and the version is bits 23..16. Writing
BADVERS into bits 16..23 puts it in the VERSION field, so a client decodes your
BADVERS as "EDNS version 16".
## Four wrong versions of one test reader
Proving the BADVERS fix took four attempts, and every failure looked like "the fix
is broken" when the fix was fine. The last reader read ANCOUNT from bytes 4..6 --
which is QDCOUNT -- so it saw ARCOUNT as 0 and returned `None` for every response,
including a correct one.
- **Dump the bytes.** Do not reason about offsets. `println!("{:02x?}", resp)`
plus a Python one-liner resolves in seconds what an hour of reading does not.
- Header counts are QD 4..6, AN 6..8, NS 8..10, AR 10..12. Getting this wrong is
easy to read as "the three counts after the flags".
- A helper returning `Option<u8>` where you expect a value will often be `None` for
a reason unrelated to the fix. Check which.
## Windows reserved ports cause 1-in-4 flakes
`bind()` on a port in the OS exclusion range returns **PermissionDenied** (10013),
not `AddrInUse`:
```
cmd /c "netsh int ipv4 show excludedportrange protocol=tcp"
```
Roughly 1,700 ports are reserved for outbound use, and `bind("127.0.0.1:0")` can
hand one back. The symptom is indirect: the resolver logs
`TCP listener failed: ... 10013`, correctly keeps serving UDP, and a TCP test then
fails with a connection refusal. It reads as resolver flakiness.
- Test harnesses must retry on a port that will not bind -- **on any error**, since
Linux reports the same collision as `AddrInUse`.
- Probe **both** transports before returning a port. A UDP-only probe picks numbers
TCP cannot use.
- Never let a harness draw a conclusion from a single listener. `transport::run`
treats a failed listener as non-fatal by design, which is right in production and
wrong to assume in a test.
## Test traps in this codebase
View on GitHubThis SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub