Razvan Dimescu e860731c01 fix: escape DNS label text per RFC 1035 §5.1 (closes #36) (#54)
* fix: escape dots and special characters in DNS label text per RFC 1035 §5.1

Closes #36

read_qname was pushing raw label bytes directly into the output string,
producing ambiguous text for labels containing dots, backslashes, or
non-printable bytes. fanf2 spotted this on HN: wire format
`[8]exa.mple[3]com[0]` (two labels, first containing a literal 0x2E)
was rendered as `exa.mple.com`, indistinguishable from three labels.

Fix both sides of the text representation per RFC 1035 §5.1:

read_qname — when rendering wire bytes to text:
- literal `.` within a label → `\.`
- literal `\` → `\\`
- bytes outside 0x21..=0x7E → `\DDD` (3-digit decimal)
- printable ASCII passes through unchanged

write_qname — when parsing text back to wire:
- `\.` produces a literal 0x2E inside the current label (not a separator)
- `\\` produces a literal 0x5C
- `\DDD` produces the byte with that decimal value (0..=255)
- unescaped `.` still separates labels, empty labels still skipped
- rejects trailing `\`, short `\DD`, and `\DDD` > 255

Impact in practice is low — real-world domains don't contain dots in
labels — but it's a correctness bug in the wire format parser that
could cause round-trip failures with adversarial input. The parser is
the core of the project, so correctness bugs take priority over
practical impact.

Adds 16 unit tests in a new `#[cfg(test)] mod tests` block covering:
plain domain read/write, literal-dot escaping on both sides, backslash
escaping, non-printable + space decimal escapes, full round-trip
preservation, and the three rejection cases for malformed escapes.

Credit: fanf2 (https://news.ycombinator.com/item?id=47612321)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: stream label writes directly into buffer (review feedback)

The first cut of this fix delegated write_qname to a helper
(parse_escaped_labels) that built Vec<Vec<u8>> up-front, then iterated
to emit the wire bytes. On a plain-ASCII domain like "www.google.com"
that's ~4 heap allocations per write_qname call, and record.rs calls
write_qname ~6 times per response — so this PR would regress
bench_buffer_serialize by roughly 24 extra allocations per response
vs. main, where the old non-escaping code had zero.

Rewrite write_qname as a streaming byte-level loop that reserves the
length byte up-front, writes the label body directly into the buffer,
then backpatches the length via set(). Zero intermediate allocations
on the common path, and the 63-byte label cap is now checked
incrementally so oversized labels fail fast.

Byte-level scanning is safe for UTF-8 input: continuation bytes are
always in 0x80..=0xBF, so they can never collide with the ASCII `.`
(0x2E) or `\` (0x5C) that drive label splitting and escape parsing.

Also inline the \DDD rendering in read_qname to avoid the per-byte
format!() allocation on non-printable input. Plain-ASCII reads hit
the unchanged push(c as char) fast path, so the common case has zero
regression.

The parse_escaped_labels helper is deleted — no remaining callers.

All 158 tests pass, clippy + fmt clean. Collapses three review
findings (HIGH allocation regression, MEDIUM format! allocation,
MEDIUM .unwrap() after digit guard) in one pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: route dnssec::name_to_wire through write_qname for escape handling

Closes #55.

dnssec::name_to_wire was a parallel implementation of the old
write_qname's splitting loop — it iterated qname.split('.') and pushed
raw bytes. It predated and duplicated the buffer.rs logic, and it did
not understand RFC 1035 §5.1 text escapes. After the read_qname fix in
this PR, names that come out of read_qname can contain \., \\, or
\DDD sequences; feeding those back into the old name_to_wire would
split on the literal '.' inside a \. sequence and produce corrupt
RRSIG signed-data blobs.

The underlying bug predates this PR — the old read_qname was broken
too, so both sides of the DNSSEC canonical form pipeline were
silently wrong in the same way. Making read_qname correct exposed the
divergence, so it's fixed here in the same PR that introduced the
exposure.

Reimplement name_to_wire on top of BytePacketBuffer::write_qname:
reserve a scratch buffer, let write_qname handle the escape parsing
and length-byte framing, copy the emitted bytes into a Vec, then
walk the wire once more to lowercase label bodies (length bytes stay
untouched). Canonical form per RFC 4034 §6.2 requires the
lowercasing, and it has to happen post-escape-resolution — a
decimal escape like \065 produces 0x41 ('A'), which must be
lowercased to 'a' in the final wire bytes.

Call sites in build_signed_data, record_to_canonical_wire,
record_rdata_canonical, and nsec3_hash are unchanged — the public
signature stays the same, infallible Vec<u8> return.

Tests:
- name_to_wire_escaped_dot_in_label_is_not_a_separator — verifies
  the fanf2 example round-trips correctly through canonical form
- name_to_wire_decimal_escape_is_lowercased — verifies post-escape
  lowercasing (the subtle correctness requirement)
- existing name_to_wire_root, name_to_wire_domain, ds_verification
  tests still pass unchanged

Test count: 158 → 160.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: tighten name_to_wire per review feedback

- Replace hand-rolled per-byte lowercase loop with stdlib
  [u8]::make_ascii_lowercase(). Shorter and idiomatic.
- Tighten the .expect() message to state the actual invariant
  (parseable DNS name) instead of vague "well-formed" language.
- Replace the doc comment's "see #55" with the real invariant —
  issue numbers rot, and by merge time #55 is closed anyway. The
  comment now explains WHY the lowercase pass has to happen
  post-escape-resolution (\065 → 'A' → 'a') instead of during
  write_qname.
- Drop the redundant `\065` test comment (the one-liner version
  is enough with the assertion showing the transform).

No behavior change; 160 tests still pass, clippy + fmt clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: cover label cap and empty-label rollback; trim doc comments

Closes coverage gaps left by PR #54:

- write_rejects_label_over_63_bytes: pins the incremental 63-byte cap
  inside write_qname's inner loop (boundary at 63 vs 64).
- write_skips_empty_labels: pins the rollback branch (pos = len_pos)
  triggered by leading or consecutive dots.

Doc comments tightened:

- write_qname: drop the streaming-impl walkthrough and the escape-grammar
  restatement (already documented on read_qname).
- name_to_wire: drop the implementation explanation; keep the
  post-escape lowercasing rationale, which pins behavior a future
  refactor could silently break.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:53:46 +03:00
2026-04-06 22:28:30 +03:00
2026-04-06 22:28:30 +03:00

Numa

CI crates.io License: MIT

DNS you own. Everywhere you go.numa.rs

A portable DNS resolver in a single binary. Block ads on any network, name your local services (frontend.numa), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.

Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). One ~8MB binary, everything embedded.

Numa dashboard

Quick Start

# macOS
brew install razvandimescu/tap/numa

# Linux
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh

# Arch Linux (AUR)
yay -S numa-git

# Windows — download from GitHub Releases
# All platforms
cargo install numa
sudo numa                              # run in foreground (port 53 requires root/admin)

Open the dashboard: http://numa.numa (or http://localhost:5380)

Set as system DNS:

Platform Install Uninstall
macOS sudo numa install sudo numa uninstall
Linux sudo numa install sudo numa uninstall
Windows numa install (admin) + reboot numa uninstall (admin) + reboot

On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry.

Local Services

Name your dev services instead of remembering port numbers:

curl -X POST localhost:5380/services \
  -d '{"name":"frontend","target_port":5173}'

Now https://frontend.numa works in your browser — green lock, valid cert, WebSocket passthrough for HMR. No mkcert, no nginx, no /etc/hosts.

Add path-based routing (app.numa/api → :5001), share services across machines via LAN discovery, or configure everything in numa.toml.

Ad Blocking & Privacy

385K+ domains blocked via Hagezi Pro. Works on any network — coffee shops, hotels, airports. Travels with your laptop.

Three resolution modes:

  • forward (default) — transparent proxy to your existing system DNS. Everything works as before, just with caching and ad blocking on top. Captive portals, VPNs, corporate DNS — all respected.
  • recursive — resolve directly from root nameservers. No upstream dependency, no single entity sees your full query pattern. Add [dnssec] enabled = true for full chain-of-trust validation.
  • auto — probe root servers on startup, recursive if reachable, encrypted DoH fallback if blocked.

DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. Read how it works →

DNS-over-TLS listener (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes:

  • Self-signed (default) — numa generates a local CA automatically. numa install adds it to the system trust store on macOS, Linux (Debian/Ubuntu, Fedora/RHEL/SUSE, Arch), and Windows. On iOS, install the .mobileconfig from numa setup-phone. Firefox keeps its own NSS store and ignores the system one — trust the CA there manually if you need HTTPS for .numa services in Firefox.
  • Bring-your-own cert — point [dot] cert_path / key_path at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare 1.1.1.1.

ALPN "dot" is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.

LAN Discovery

Run Numa on multiple machines. They find each other automatically via mDNS:

Machine A (192.168.1.5)              Machine B (192.168.1.20)
┌──────────────────────┐             ┌──────────────────────┐
│ Numa                 │    mDNS     │ Numa                 │
│  - api (port 8000)   │◄───────────►│  - grafana (3000)    │
│  - frontend (5173)   │  discovery  │                      │
└──────────────────────┘             └──────────────────────┘

From Machine B: curl http://api.numa → proxied to Machine A's port 8000. Enable with numa lan on.

Hub mode: run one instance with bind_addr = "0.0.0.0:53" and point other devices' DNS to it — they get ad blocking + .numa resolution without installing anything.

How It Compares

Pi-hole AdGuard Home Unbound Numa
Local service proxy + auto TLS .numa domains, HTTPS, WebSocket
LAN service discovery mDNS, zero config
Developer overrides (REST API) Auto-revert, scriptable
Recursive resolver Yes Yes, with SRTT selection
DNSSEC validation Yes Yes (RSA, ECDSA, Ed25519)
Ad blocking Yes Yes 385K+ domains
Web admin UI Full Full Dashboard
Encrypted upstream (DoH) Needs cloudflared Yes Native
Encrypted clients (DoT listener) Needs stunnel sidecar Yes Yes Native (RFC 7858)
Portable (laptop) No (appliance) No (appliance) Server Single binary, macOS/Linux/Windows
Community maturity 56K stars, 10 years 33K stars 20 years New

Performance

691ns cached round-trip. ~2.0M qps throughput. Zero heap allocations in the hot path. Recursive queries average 237ms after SRTT warmup (12x improvement over round-robin). ECDSA P-256 DNSSEC verification: 174ns. Benchmarks →

Learn More

Roadmap

  • DNS forwarding, caching, ad blocking, developer overrides
  • .numa local domains — auto TLS, path routing, WebSocket proxy
  • LAN service discovery — mDNS, cross-machine DNS + proxy
  • DNS-over-HTTPS — encrypted upstream
  • DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict)
  • Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
  • SRTT-based nameserver selection
  • pkarr integration — self-sovereign DNS via Mainline DHT
  • Global .numa names — DHT-backed, no registrar

License

MIT

Description
Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides
Readme MIT 4.1 MiB
v0.14.2 Latest
2026-04-23 04:57:37 +08:00
Languages
Rust 76%
HTML 12.1%
Shell 11.4%
Python 0.2%
Makefile 0.1%