From aed0e095e1f1072b175ed34b70b443d397fdf868 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Tue, 24 Mar 2026 22:51:34 +0200 Subject: [PATCH 1/5] =?UTF-8?q?perf:=20optimize=20hot=20path=20=E2=80=94?= =?UTF-8?q?=20RwLock,=20inline=20filtering,=20pre-allocated=20strings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mutex → RwLock for cache, blocklist, and overrides (concurrent read access) - Make cache.lookup() and overrides.lookup() take &self (read-only) - Eliminate 3 Vec allocations per DnsPacket::write() via inline filtering - Pre-allocate domain strings with capacity 64 in parse path - Add criterion micro-benchmarks (hot_path + throughput) - Add bench README documenting both benchmark suites Measured improvement: ~14% faster parsing, ~9% pipeline throughput, round-trip cached 733ns → 698ns (~2.3M queries/sec). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 267 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 11 ++ Makefile | 13 +- bench/README.md | 87 ++++++++++++++ benches/hot_path.rs | 186 +++++++++++++++++++++++++++++ benches/throughput.rs | 94 +++++++++++++++ src/api.rs | 51 ++++---- src/cache.rs | 18 +-- src/ctx.rs | 16 +-- src/main.rs | 10 +- src/override_store.rs | 7 +- src/packet.rs | 38 +++--- src/record.rs | 8 +- 13 files changed, 729 insertions(+), 77 deletions(-) create mode 100644 bench/README.md create mode 100644 benches/hot_path.rs create mode 100644 benches/throughput.rs diff --git a/Cargo.lock b/Cargo.lock index bd15955..a8563e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -237,6 +243,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.57" @@ -261,6 +273,58 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -302,6 +366,73 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "data-encoding" version = "2.10.0" @@ -348,6 +479,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "env_filter" version = "1.0.0" @@ -548,12 +685,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -790,12 +944,32 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -971,6 +1145,7 @@ version = "0.5.0" dependencies = [ "arc-swap", "axum", + "criterion", "env_logger", "futures", "http-body-util", @@ -1010,6 +1185,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "pem" version = "3.0.6" @@ -1038,6 +1219,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1185,6 +1394,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -1346,6 +1575,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.228" @@ -1589,6 +1827,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1807,6 +2055,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1919,6 +2177,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index fa52afa..ea71da7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,14 @@ time = "0.3" rustls = "0.23" tokio-rustls = "0.26" arc-swap = "1" + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "hot_path" +harness = false + +[[bench]] +name = "throughput" +harness = false diff --git a/Makefile b/Makefile index d25d697..643c058 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build lint fmt check audit test clean deploy +.PHONY: all build lint fmt check audit test bench clean deploy blog all: lint build @@ -19,6 +19,17 @@ audit: test: cargo test +bench: + cargo bench + +blog: + @mkdir -p site/blog + @for f in blog/*.md; do \ + name=$$(basename "$$f" .md); \ + pandoc "$$f" --template=site/blog-template.html -o "site/blog/$$name.html"; \ + echo " $$f → site/blog/$$name.html"; \ + done + clean: cargo clean diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..7307369 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,87 @@ +# Benchmarks + +Numa has two benchmark suites measuring different layers of performance. + +## Micro-benchmarks (`benches/`, criterion) + +Nanosecond-precision measurement of individual operations on the hot path. +No running server required — these are pure Rust unit-level benchmarks. + +```sh +cargo bench # run all +cargo bench --bench hot_path # parse, serialize, cache, clone +cargo bench --bench throughput # pipeline QPS, buffer alloc +``` + +### What's measured + +**hot_path** — individual operations: + +| Benchmark | What it measures | +|-----------|-----------------| +| `buffer_parse` | Wire bytes → DnsPacket (typical response with 4 records) | +| `buffer_serialize` | DnsPacket → wire bytes | +| `packet_clone` | Full DnsPacket clone (what cache hit costs) | +| `cache_lookup_hit` | Cache lookup on a single-entry cache | +| `cache_lookup_hit_populated` | Cache lookup with 1000 entries | +| `cache_lookup_miss` | HashMap miss (baseline) | +| `cache_insert` | Insert into cache with packet clone | +| `round_trip_cached` | Full cached path: parse query → cache hit → serialize response | + +**throughput** — pipeline capacity: + +| Benchmark | What it measures | +|-----------|-----------------| +| `pipeline_throughput/N` | N cached queries end-to-end (parse → lookup → serialize) | +| `buffer_alloc` | BytePacketBuffer 4KB zero-init cost | + +### Reading results + +Criterion auto-compares against the previous run: + +``` +round_trip_cached time: [710.5 ns 715.2 ns 720.1 ns] + change: [-2.48% -1.85% -1.21%] (p = 0.00 < 0.05) + Performance has improved. +``` + +- The three values are [lower bound, estimate, upper bound] of the mean +- `change` shows the delta vs the last saved baseline +- HTML reports with charts: `target/criterion/report/index.html` + +To save a named baseline for comparison: + +```sh +cargo bench -- --save-baseline before +# ... make changes ... +cargo bench -- --baseline before +``` + +## End-to-end benchmark (`bench/dns-bench.sh`) + +Real-world latency comparison using `dig` against a running Numa instance +and public resolvers. Measures millisecond-level latency including network I/O. + +```sh +# Start Numa first (default port 15353 for testing) +python3 bench/dns-bench.sh [port] [rounds] +python3 bench/dns-bench.sh 15353 20 # default +``` + +### What's measured + +- **Numa (cold)**: cache flushed before each query — measures upstream forwarding +- **Numa (cached)**: queries hit cache — measures local processing +- **System / Google / Cloudflare / Quad9**: public resolver comparison + +Results saved to `bench/results.json`. + +### When to use which + +| Question | Use | +|----------|-----| +| Did my code change make parsing faster? | `cargo bench --bench hot_path` | +| Is the cached path still sub-microsecond? | `cargo bench --bench hot_path` (round_trip_cached) | +| How many queries/sec can we handle? | `cargo bench --bench throughput` | +| Is Numa still competitive with system resolver? | `bench/dns-bench.sh` | +| Did upstream forwarding regress? | `bench/dns-bench.sh` | diff --git a/benches/hot_path.rs b/benches/hot_path.rs new file mode 100644 index 0000000..ecd84ae --- /dev/null +++ b/benches/hot_path.rs @@ -0,0 +1,186 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::net::Ipv4Addr; + +use numa::buffer::BytePacketBuffer; +use numa::cache::DnsCache; +use numa::header::{DnsHeader, ResultCode}; +use numa::packet::DnsPacket; +use numa::question::{DnsQuestion, QueryType}; +use numa::record::DnsRecord; + +fn make_response(domain: &str) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header = DnsHeader::new(); + pkt.header.id = 0x1234; + pkt.header.response = true; + pkt.header.recursion_desired = true; + pkt.header.recursion_available = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + pkt.answers.push(DnsRecord::A { + domain: domain.to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + // Typical response includes authority + additional records + pkt.authorities.push(DnsRecord::NS { + domain: domain.to_string(), + host: format!("ns1.{domain}"), + ttl: 172800, + }); + pkt.authorities.push(DnsRecord::NS { + domain: domain.to_string(), + host: format!("ns2.{domain}"), + ttl: 172800, + }); + pkt.resources.push(DnsRecord::A { + domain: format!("ns1.{domain}"), + addr: Ipv4Addr::new(198, 51, 100, 1), + ttl: 172800, + }); + pkt +} + +fn to_wire(pkt: &DnsPacket) -> Vec { + let mut buf = BytePacketBuffer::new(); + pkt.write(&mut buf).unwrap(); + buf.filled().to_vec() +} + +fn bench_buffer_parse(c: &mut Criterion) { + let pkt = make_response("example.com"); + let wire = to_wire(&pkt); + + c.bench_function("buffer_parse", |b| { + b.iter(|| { + let mut buf = BytePacketBuffer::from_bytes(black_box(&wire)); + DnsPacket::from_buffer(&mut buf).unwrap() + }) + }); +} + +fn bench_buffer_serialize(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("buffer_serialize", |b| { + b.iter(|| { + let mut buf = BytePacketBuffer::new(); + black_box(&pkt).write(&mut buf).unwrap(); + black_box(buf.pos()); + }) + }); +} + +fn bench_packet_clone(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("packet_clone", |b| b.iter(|| black_box(&pkt).clone())); +} + +fn bench_cache_lookup_hit(c: &mut Criterion) { + let mut cache = DnsCache::new(10_000, 60, 86400); + let pkt = make_response("example.com"); + cache.insert("example.com", QueryType::A, &pkt); + + c.bench_function("cache_lookup_hit", |b| { + b.iter(|| { + cache + .lookup(black_box("example.com"), QueryType::A) + .unwrap() + }) + }); +} + +fn bench_cache_lookup_miss(c: &mut Criterion) { + let mut cache = DnsCache::new(10_000, 60, 86400); + + c.bench_function("cache_lookup_miss", |b| { + b.iter(|| cache.lookup(black_box("nonexistent.com"), QueryType::A)) + }); +} + +fn bench_cache_insert(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("cache_insert", |b| { + let mut cache = DnsCache::new(10_000, 60, 86400); + let mut i = 0u64; + b.iter(|| { + let domain = format!("bench-{i}.example.com"); + cache.insert(&domain, QueryType::A, black_box(&pkt)); + i += 1; + // Reset cache periodically to avoid filling up + if i % 5000 == 0 { + cache.clear(); + } + }) + }); +} + +fn bench_round_trip(c: &mut Criterion) { + // Simulates the cached hot path: parse query → cache hit → serialize response + let query_pkt = { + let mut q = DnsPacket::new(); + q.header.id = 0xABCD; + q.header.recursion_desired = true; + q.questions + .push(DnsQuestion::new("example.com".to_string(), QueryType::A)); + q + }; + let query_wire = to_wire(&query_pkt); + + let response = make_response("example.com"); + let mut cache = DnsCache::new(10_000, 60, 86400); + cache.insert("example.com", QueryType::A, &response); + + c.bench_function("round_trip_cached", |b| { + b.iter(|| { + // 1. Parse incoming query + let mut buf = BytePacketBuffer::from_bytes(black_box(&query_wire)); + let query = DnsPacket::from_buffer(&mut buf).unwrap(); + let qname = &query.questions[0].name; + let qtype = query.questions[0].qtype; + + // 2. Cache lookup + let mut resp = cache.lookup(qname, qtype).unwrap(); + resp.header.id = query.header.id; + + // 3. Serialize response + let mut resp_buf = BytePacketBuffer::new(); + resp.write(&mut resp_buf).unwrap(); + black_box(resp_buf.pos()); + }) + }); +} + +fn bench_cache_populated_lookup(c: &mut Criterion) { + // Benchmark with a realistically populated cache (1000 entries) + let mut cache = DnsCache::new(10_000, 60, 86400); + for i in 0..1000 { + let domain = format!("domain-{i}.example.com"); + let pkt = make_response(&domain); + cache.insert(&domain, QueryType::A, &pkt); + } + + c.bench_function("cache_lookup_hit_populated", |b| { + b.iter(|| { + cache + .lookup(black_box("domain-500.example.com"), QueryType::A) + .unwrap() + }) + }); +} + +criterion_group!( + benches, + bench_buffer_parse, + bench_buffer_serialize, + bench_packet_clone, + bench_cache_lookup_hit, + bench_cache_lookup_miss, + bench_cache_insert, + bench_round_trip, + bench_cache_populated_lookup, +); +criterion_main!(benches); diff --git a/benches/throughput.rs b/benches/throughput.rs new file mode 100644 index 0000000..688b4e0 --- /dev/null +++ b/benches/throughput.rs @@ -0,0 +1,94 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use std::net::Ipv4Addr; + +use numa::buffer::BytePacketBuffer; +use numa::header::ResultCode; +use numa::packet::DnsPacket; +use numa::question::{DnsQuestion, QueryType}; +use numa::record::DnsRecord; + +fn make_query_wire(domain: &str) -> Vec { + let mut q = DnsPacket::new(); + q.header.id = 0xABCD; + q.header.recursion_desired = true; + q.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + let mut buf = BytePacketBuffer::new(); + q.write(&mut buf).unwrap(); + buf.filled().to_vec() +} + +fn make_response(domain: &str) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.id = 0xABCD; + pkt.header.response = true; + pkt.header.recursion_desired = true; + pkt.header.recursion_available = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + pkt.answers.push(DnsRecord::A { + domain: domain.to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + pkt +} + +/// Simulates the complete cached query pipeline (sans network I/O): +/// parse → cache lookup → TTL adjust → serialize response +fn simulate_cached_pipeline(query_wire: &[u8], cache: &mut numa::cache::DnsCache) -> usize { + let mut buf = BytePacketBuffer::from_bytes(query_wire); + let query = DnsPacket::from_buffer(&mut buf).unwrap(); + let q = &query.questions[0]; + + let mut resp = cache.lookup(&q.name, q.qtype).unwrap(); + resp.header.id = query.header.id; + + let mut resp_buf = BytePacketBuffer::new(); + resp.write(&mut resp_buf).unwrap(); + resp_buf.pos() +} + +fn bench_pipeline_throughput(c: &mut Criterion) { + let domains: Vec = (0..100) + .map(|i| format!("domain-{i}.example.com")) + .collect(); + + let mut cache = numa::cache::DnsCache::new(10_000, 60, 86400); + for d in &domains { + cache.insert(d, QueryType::A, &make_response(d)); + } + + let query_wires: Vec> = domains.iter().map(|d| make_query_wire(d)).collect(); + + let mut group = c.benchmark_group("pipeline_throughput"); + + for count in [1, 10, 100] { + group.throughput(Throughput::Elements(count)); + group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, &count| { + let mut idx = 0usize; + b.iter(|| { + for _ in 0..count { + let wire = &query_wires[idx % query_wires.len()]; + simulate_cached_pipeline(wire, &mut cache); + idx += 1; + } + }); + }); + } + group.finish(); +} + +/// Measures the overhead of BytePacketBuffer allocation + zero-init +fn bench_buffer_alloc(c: &mut Criterion) { + c.bench_function("buffer_alloc", |b| { + b.iter(|| { + let buf = BytePacketBuffer::new(); + criterion::black_box(buf.pos()); + }) + }); +} + +criterion_group!(benches, bench_pipeline_throughput, bench_buffer_alloc,); +criterion_main!(benches); diff --git a/src/api.rs b/src/api.rs index 2df9d73..1c6283c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -220,7 +220,7 @@ async fn create_overrides( }) .collect::, (StatusCode, String)>>()?; - let mut store = ctx.overrides.lock().unwrap(); + let mut store = ctx.overrides.write().unwrap(); let mut responses = Vec::with_capacity(parsed.len()); for (domain, target, ttl, duration_secs) in parsed { @@ -241,7 +241,7 @@ async fn create_overrides( } async fn list_overrides(State(ctx): State>) -> Json> { - let store = ctx.overrides.lock().unwrap(); + let store = ctx.overrides.read().unwrap(); let entries: Vec = store .list() .into_iter() @@ -254,7 +254,7 @@ async fn get_override( State(ctx): State>, Path(domain): Path, ) -> Result, StatusCode> { - let store = ctx.overrides.lock().unwrap(); + let store = ctx.overrides.read().unwrap(); let entry = store.get(&domain).ok_or(StatusCode::NOT_FOUND)?; Ok(Json(OverrideResponse::from(entry))) } @@ -263,7 +263,7 @@ async fn remove_override( State(ctx): State>, Path(domain): Path, ) -> StatusCode { - let mut store = ctx.overrides.lock().unwrap(); + let mut store = ctx.overrides.write().unwrap(); if store.remove(&domain) { StatusCode::NO_CONTENT } else { @@ -272,7 +272,7 @@ async fn remove_override( } async fn clear_overrides(State(ctx): State>) -> StatusCode { - ctx.overrides.lock().unwrap().clear(); + ctx.overrides.write().unwrap().clear(); StatusCode::NO_CONTENT } @@ -280,7 +280,7 @@ async fn load_environment( State(ctx): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { - let mut store = ctx.overrides.lock().unwrap(); + let mut store = ctx.overrides.write().unwrap(); for entry in &req.overrides { let duration = entry.duration_secs.or(req.duration_secs); @@ -307,7 +307,7 @@ async fn diagnose( // Check overrides { - let store = ctx.overrides.lock().unwrap(); + let store = ctx.overrides.read().unwrap(); let entry = store.get(&domain_lower); steps.push(DiagnoseStep { source: "override".to_string(), @@ -319,7 +319,7 @@ async fn diagnose( // Check blocklist { - let bl = ctx.blocklist.lock().unwrap(); + let bl = ctx.blocklist.read().unwrap(); let blocked = bl.is_blocked(&domain_lower); steps.push(DiagnoseStep { source: "blocklist".to_string(), @@ -345,7 +345,7 @@ async fn diagnose( // Check cache { - let mut cache = ctx.cache.lock().unwrap(); + let cache = ctx.cache.read().unwrap(); let cached = cache.lookup(&domain_lower, qtype); steps.push(DiagnoseStep { source: "cache".to_string(), @@ -443,11 +443,11 @@ async fn query_log( async fn stats(State(ctx): State>) -> Json { let snap = ctx.stats.lock().unwrap().snapshot(); let (cache_len, cache_max) = { - let cache = ctx.cache.lock().unwrap(); + let cache = ctx.cache.read().unwrap(); (cache.len(), cache.max_entries()) }; - let override_count = ctx.overrides.lock().unwrap().active_count(); - let bl_stats = ctx.blocklist.lock().unwrap().stats(); + let override_count = ctx.overrides.read().unwrap().active_count(); + let bl_stats = ctx.blocklist.read().unwrap().stats(); let upstream = ctx.upstream.lock().unwrap().to_string(); @@ -486,7 +486,7 @@ async fn stats(State(ctx): State>) -> Json { } async fn list_cache(State(ctx): State>) -> Json> { - let cache = ctx.cache.lock().unwrap(); + let cache = ctx.cache.read().unwrap(); let entries: Vec = cache .list() .into_iter() @@ -500,7 +500,7 @@ async fn list_cache(State(ctx): State>) -> Json>) -> StatusCode { - ctx.cache.lock().unwrap().clear(); + ctx.cache.write().unwrap().clear(); StatusCode::NO_CONTENT } @@ -508,7 +508,7 @@ async fn flush_cache_domain( State(ctx): State>, Path(domain): Path, ) -> StatusCode { - ctx.cache.lock().unwrap().remove(&domain); + ctx.cache.write().unwrap().remove(&domain); StatusCode::NO_CONTENT } @@ -519,7 +519,7 @@ async fn health() -> Json { // --- Blocking handlers --- async fn blocking_stats(State(ctx): State>) -> Json { - let stats = ctx.blocklist.lock().unwrap().stats(); + let stats = ctx.blocklist.read().unwrap().stats(); Json(serde_json::json!({ "enabled": stats.enabled, "paused": stats.paused, @@ -539,7 +539,7 @@ async fn blocking_toggle( State(ctx): State>, Json(req): Json, ) -> Json { - ctx.blocklist.lock().unwrap().set_enabled(req.enabled); + ctx.blocklist.write().unwrap().set_enabled(req.enabled); Json(serde_json::json!({ "enabled": req.enabled })) } @@ -557,12 +557,12 @@ async fn blocking_pause( State(ctx): State>, Json(req): Json, ) -> Json { - ctx.blocklist.lock().unwrap().pause(req.minutes * 60); + ctx.blocklist.write().unwrap().pause(req.minutes * 60); Json(serde_json::json!({ "paused_minutes": req.minutes })) } async fn blocking_unpause(State(ctx): State>) -> Json { - ctx.blocklist.lock().unwrap().unpause(); + ctx.blocklist.write().unwrap().unpause(); Json(serde_json::json!({ "paused": false })) } @@ -570,12 +570,12 @@ async fn blocking_check( State(ctx): State>, Path(domain): Path, ) -> Json { - let result = ctx.blocklist.lock().unwrap().check(&domain); + let result = ctx.blocklist.read().unwrap().check(&domain); Json(result) } async fn blocking_allowlist(State(ctx): State>) -> Json> { - let list = ctx.blocklist.lock().unwrap().allowlist(); + let list = ctx.blocklist.read().unwrap().allowlist(); Json(list) } @@ -588,7 +588,7 @@ async fn blocking_allowlist_add( State(ctx): State>, Json(req): Json, ) -> (StatusCode, Json) { - ctx.blocklist.lock().unwrap().add_to_allowlist(&req.domain); + ctx.blocklist.write().unwrap().add_to_allowlist(&req.domain); ( StatusCode::CREATED, Json(serde_json::json!({ "allowed": req.domain })), @@ -599,7 +599,12 @@ async fn blocking_allowlist_remove( State(ctx): State>, Path(domain): Path, ) -> StatusCode { - if ctx.blocklist.lock().unwrap().remove_from_allowlist(&domain) { + if ctx + .blocklist + .write() + .unwrap() + .remove_from_allowlist(&domain) + { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND diff --git a/src/cache.rs b/src/cache.rs index 0586bc9..decde82 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -19,7 +19,6 @@ pub struct DnsCache { max_entries: usize, min_ttl: u32, max_ttl: u32, - query_count: u64, } impl DnsCache { @@ -30,29 +29,16 @@ impl DnsCache { max_entries, min_ttl, max_ttl, - query_count: 0, } } - pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option { - self.query_count += 1; - - if self.query_count.is_multiple_of(1000) { - self.evict_expired(); - } - + /// Read-only lookup — expired entries are left in place (cleaned up on insert). + pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option { let type_map = self.entries.get(domain)?; let entry = type_map.get(&qtype)?; let elapsed = entry.inserted_at.elapsed(); if elapsed >= entry.ttl { - // Expired: remove this entry - let type_map = self.entries.get_mut(domain).unwrap(); - type_map.remove(&qtype); - self.entry_count -= 1; - if type_map.is_empty() { - self.entries.remove(domain); - } return None; } diff --git a/src/ctx.rs b/src/ctx.rs index 925ab4a..80b9226 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,6 +1,6 @@ use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::{Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime}; use arc_swap::ArcSwap; @@ -27,10 +27,10 @@ use crate::system_dns::ForwardingRule; pub struct ServerCtx { pub socket: UdpSocket, pub zone_map: ZoneMap, - pub cache: Mutex, + pub cache: RwLock, pub stats: Mutex, - pub overrides: Mutex, - pub blocklist: Mutex, + pub overrides: RwLock, + pub blocklist: RwLock, pub query_log: Mutex, pub services: Mutex, pub lan_peers: Mutex, @@ -73,7 +73,7 @@ pub async fn handle_query( // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream // Each lock is scoped to avoid holding MutexGuard across await points. let (response, path) = { - let override_record = ctx.overrides.lock().unwrap().lookup(&qname); + let override_record = ctx.overrides.read().unwrap().lookup(&qname); if let Some(record) = override_record { let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); resp.answers.push(record); @@ -116,7 +116,7 @@ pub async fn handle_query( }), } (resp, QueryPath::Local) - } else if ctx.blocklist.lock().unwrap().is_blocked(&qname) { + } else if ctx.blocklist.read().unwrap().is_blocked(&qname) { let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); match qtype { QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { @@ -136,7 +136,7 @@ pub async fn handle_query( resp.answers = records.clone(); (resp, QueryPath::Local) } else { - let cached = ctx.cache.lock().unwrap().lookup(&qname, qtype); + let cached = ctx.cache.read().unwrap().lookup(&qname, qtype); if let Some(cached) = cached { let mut resp = cached; resp.header.id = query.header.id; @@ -149,7 +149,7 @@ pub async fn handle_query( }; match forward_query(&query, &upstream, ctx.timeout).await { Ok(resp) => { - ctx.cache.lock().unwrap().insert(&qname, qtype, &resp); + ctx.cache.write().unwrap().insert(&qname, qtype, &resp); (resp, QueryPath::Forwarded) } Err(e) => { diff --git a/src/main.rs b/src/main.rs index 8e23e78..7e739aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use arc_swap::ArcSwap; @@ -170,14 +170,14 @@ async fn main() -> numa::Result<()> { let ctx = Arc::new(ServerCtx { socket: UdpSocket::bind(&config.server.bind_addr).await?, zone_map: build_zone_map(&config.zones)?, - cache: Mutex::new(DnsCache::new( + cache: RwLock::new(DnsCache::new( config.cache.max_entries, config.cache.min_ttl, config.cache.max_ttl, )), stats: Mutex::new(ServerStats::new()), - overrides: Mutex::new(OverrideStore::new()), - blocklist: Mutex::new(blocklist), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(blocklist), query_log: Mutex::new(QueryLog::new(1000)), services: Mutex::new(service_store), lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), @@ -541,7 +541,7 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { // Swap under lock — sub-microsecond ctx.blocklist - .lock() + .write() .unwrap() .swap_domains(all_domains, sources); info!( diff --git a/src/override_store.rs b/src/override_store.rs index a1c7bf8..2ae671c 100644 --- a/src/override_store.rs +++ b/src/override_store.rs @@ -64,6 +64,9 @@ impl OverrideStore { ttl: u32, duration_secs: Option, ) -> Result { + // Clean up expired entries on write + self.entries.retain(|_, e| !e.is_expired()); + let domain_lower = domain.to_lowercase(); let (qtype, record) = parse_target(&domain_lower, target, ttl)?; @@ -84,10 +87,10 @@ impl OverrideStore { } /// Hot path: assumes `domain` is already lowercased (the parser does this). - pub fn lookup(&mut self, domain: &str) -> Option { + /// Read-only — expired entries are left in place (cleaned up on write operations). + pub fn lookup(&self, domain: &str) -> Option { let entry = self.entries.get(domain)?; if entry.is_expired() { - self.entries.remove(domain); return None; } Some(entry.record.clone()) diff --git a/src/packet.rs b/src/packet.rs index bca60c2..2c4c85a 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -46,7 +46,7 @@ impl DnsPacket { result.header.read(buffer)?; for _ in 0..result.header.questions { - let mut question = DnsQuestion::new("".to_string(), QueryType::UNKNOWN(0)); + let mut question = DnsQuestion::new(String::with_capacity(64), QueryType::UNKNOWN(0)); question.read(buffer)?; result.questions.push(question); } @@ -68,34 +68,36 @@ impl DnsPacket { } pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> { - // Filter out UNKNOWN records (e.g. EDNS OPT) that we can't re-serialize - let answers: Vec<_> = self.answers.iter().filter(|r| !r.is_unknown()).collect(); - let authorities: Vec<_> = self - .authorities - .iter() - .filter(|r| !r.is_unknown()) - .collect(); - let resources: Vec<_> = self.resources.iter().filter(|r| !r.is_unknown()).collect(); + // Count known records without allocating filter Vecs + let answer_count = self.answers.iter().filter(|r| !r.is_unknown()).count() as u16; + let auth_count = self.authorities.iter().filter(|r| !r.is_unknown()).count() as u16; + let res_count = self.resources.iter().filter(|r| !r.is_unknown()).count() as u16; let mut header = self.header.clone(); header.questions = self.questions.len() as u16; - header.answers = answers.len() as u16; - header.authoritative_entries = authorities.len() as u16; - header.resource_entries = resources.len() as u16; + header.answers = answer_count; + header.authoritative_entries = auth_count; + header.resource_entries = res_count; header.write(buffer)?; for question in &self.questions { question.write(buffer)?; } - for rec in answers { - rec.write(buffer)?; + for rec in &self.answers { + if !rec.is_unknown() { + rec.write(buffer)?; + } } - for rec in authorities { - rec.write(buffer)?; + for rec in &self.authorities { + if !rec.is_unknown() { + rec.write(buffer)?; + } } - for rec in resources { - rec.write(buffer)?; + for rec in &self.resources { + if !rec.is_unknown() { + rec.write(buffer)?; + } } Ok(()) diff --git a/src/record.rs b/src/record.rs index f525cbb..b7522dc 100644 --- a/src/record.rs +++ b/src/record.rs @@ -70,7 +70,7 @@ impl DnsRecord { } pub fn read(buffer: &mut BytePacketBuffer) -> Result { - let mut domain = String::new(); + let mut domain = String::with_capacity(64); buffer.read_qname(&mut domain)?; let qtype_num = buffer.read_u16()?; @@ -110,7 +110,7 @@ impl DnsRecord { Ok(DnsRecord::AAAA { domain, addr, ttl }) } QueryType::NS => { - let mut ns = String::new(); + let mut ns = String::with_capacity(64); buffer.read_qname(&mut ns)?; Ok(DnsRecord::NS { @@ -120,7 +120,7 @@ impl DnsRecord { }) } QueryType::CNAME => { - let mut cname = String::new(); + let mut cname = String::with_capacity(64); buffer.read_qname(&mut cname)?; Ok(DnsRecord::CNAME { @@ -131,7 +131,7 @@ impl DnsRecord { } QueryType::MX => { let priority = buffer.read_u16()?; - let mut mx = String::new(); + let mut mx = String::with_capacity(64); buffer.read_qname(&mut mx)?; Ok(DnsRecord::MX { -- 2.34.1 From f8e340ca1724515bd428753d94c200361bdec7c2 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Wed, 25 Mar 2026 06:14:00 +0200 Subject: [PATCH 2/5] chore: simplify benchmark code after review - Remove redundant DnsHeader::new() (already set by DnsPacket::new()) - Remove unused DnsHeader import - Change simulate_cached_pipeline to take &DnsCache (lookup is &self now) - Remove unnecessary mut on cache in cache_lookup_miss bench Co-Authored-By: Claude Opus 4.6 (1M context) --- benches/hot_path.rs | 5 ++--- benches/throughput.rs | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/benches/hot_path.rs b/benches/hot_path.rs index ecd84ae..accfcf2 100644 --- a/benches/hot_path.rs +++ b/benches/hot_path.rs @@ -3,14 +3,13 @@ use std::net::Ipv4Addr; use numa::buffer::BytePacketBuffer; use numa::cache::DnsCache; -use numa::header::{DnsHeader, ResultCode}; +use numa::header::ResultCode; use numa::packet::DnsPacket; use numa::question::{DnsQuestion, QueryType}; use numa::record::DnsRecord; fn make_response(domain: &str) -> DnsPacket { let mut pkt = DnsPacket::new(); - pkt.header = DnsHeader::new(); pkt.header.id = 0x1234; pkt.header.response = true; pkt.header.recursion_desired = true; @@ -93,7 +92,7 @@ fn bench_cache_lookup_hit(c: &mut Criterion) { } fn bench_cache_lookup_miss(c: &mut Criterion) { - let mut cache = DnsCache::new(10_000, 60, 86400); + let cache = DnsCache::new(10_000, 60, 86400); c.bench_function("cache_lookup_miss", |b| { b.iter(|| cache.lookup(black_box("nonexistent.com"), QueryType::A)) diff --git a/benches/throughput.rs b/benches/throughput.rs index 688b4e0..e01a25c 100644 --- a/benches/throughput.rs +++ b/benches/throughput.rs @@ -37,7 +37,7 @@ fn make_response(domain: &str) -> DnsPacket { /// Simulates the complete cached query pipeline (sans network I/O): /// parse → cache lookup → TTL adjust → serialize response -fn simulate_cached_pipeline(query_wire: &[u8], cache: &mut numa::cache::DnsCache) -> usize { +fn simulate_cached_pipeline(query_wire: &[u8], cache: &numa::cache::DnsCache) -> usize { let mut buf = BytePacketBuffer::from_bytes(query_wire); let query = DnsPacket::from_buffer(&mut buf).unwrap(); let q = &query.questions[0]; @@ -71,7 +71,7 @@ fn bench_pipeline_throughput(c: &mut Criterion) { b.iter(|| { for _ in 0..count { let wire = &query_wires[idx % query_wires.len()]; - simulate_cached_pipeline(wire, &mut cache); + simulate_cached_pipeline(wire, &cache); idx += 1; } }); -- 2.34.1 From 38b5cd2cce4b156fa7de16b66d7a1c58f78e2d66 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 27 Mar 2026 00:30:50 +0200 Subject: [PATCH 3/5] site: landing page overhaul, blog, benchmarks, numa.rs domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing page: - Split features into 3-layer card layout (Block & Protect, Developer Tools, Self-Sovereign DNS) - Add DoH and conditional forwarding to comparison table - Fix performance claim (2.3M → 2.0M qps to match benchmarks) - Add all 3 install methods (brew, cargo, curl) - Add OG tags + canonical URL for numa.rs - Fix code block whitespace rendering - Update roadmap with .onion bridge phase Blog: - Add "Building a DNS Resolver from Scratch in Rust" post - Blog index + template for future posts Other: - CNAME for GitHub Pages (numa.rs) - Benchmark results (bench/results.json) Co-Authored-By: Claude Opus 4.6 (1M context) --- bench/results.json | 50 +++ blog/dns-from-scratch.md | 363 ++++++++++++++++ site/CNAME | 1 + site/blog-template.html | 303 ++++++++++++++ site/blog/dns-from-scratch.html | 717 ++++++++++++++++++++++++++++++++ site/blog/index.html | 188 +++++++++ site/index.html | 465 ++++++++++++++++----- 7 files changed, 1977 insertions(+), 110 deletions(-) create mode 100644 bench/results.json create mode 100644 blog/dns-from-scratch.md create mode 100644 site/CNAME create mode 100644 site/blog-template.html create mode 100644 site/blog/dns-from-scratch.html create mode 100644 site/blog/index.html diff --git a/bench/results.json b/bench/results.json new file mode 100644 index 0000000..934835e --- /dev/null +++ b/bench/results.json @@ -0,0 +1,50 @@ +{ + "Numa(cold)": { + "avg": 9, + "p50": 9, + "p99": 18, + "min": 8, + "max": 18, + "count": 50 + }, + "Numa(cached)": { + "avg": 0, + "p50": 0, + "p99": 0, + "min": 0, + "max": 0, + "count": 50 + }, + "System": { + "avg": 9.1, + "p50": 8, + "p99": 44, + "min": 7, + "max": 44, + "count": 50 + }, + "Google": { + "avg": 22.4, + "p50": 17, + "p99": 37, + "min": 13, + "max": 37, + "count": 50 + }, + "Cloudflare": { + "avg": 18.7, + "p50": 14, + "p99": 132, + "min": 12, + "max": 132, + "count": 50 + }, + "Quad9": { + "avg": 14.5, + "p50": 13, + "p99": 43, + "min": 12, + "max": 43, + "count": 50 + } +} \ No newline at end of file diff --git a/blog/dns-from-scratch.md b/blog/dns-from-scratch.md new file mode 100644 index 0000000..ebd6993 --- /dev/null +++ b/blog/dns-from-scratch.md @@ -0,0 +1,363 @@ +--- +title: I Built a DNS Resolver from Scratch in Rust +description: How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries. +date: March 2026 +--- + +I wanted to understand how DNS actually works. Not the "it translates domain names to IP addresses" explanation — the actual bytes on the wire. What does a DNS packet look like? How does label compression work? Why is everything crammed into 512 bytes? + +So I built one from scratch in Rust. No `hickory-dns`, no `trust-dns`, no `simple-dns`. The entire RFC 1035 wire protocol — headers, labels, compression pointers, record types — parsed and serialized by hand. It started as a weekend learning project, became a side project I kept coming back to over 6 years, and eventually turned into [Numa](https://github.com/razvandimescu/numa) — which I now use as my actual system DNS. + +A note on terminology before we go further: Numa is currently a *forwarding* resolver — it parses and caches DNS packets, but forwards queries to an upstream (Quad9, Cloudflare, or any DoH provider) rather than walking the delegation chain from root servers itself. Think of it as a smart proxy that does useful things with your DNS traffic locally (caching, ad blocking, overrides, local service domains) before forwarding what it can't answer. Full recursive resolution — where Numa talks directly to root and authoritative nameservers — is on the roadmap, along with DNSSEC validation. + +Here's what surprised me along the way. + +## What does a DNS packet actually look like? + +You can see a real one yourself. Run this: + +```bash +dig @127.0.0.1 example.com A +noedns +``` + +``` +;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 15242 +;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0 + +;; QUESTION SECTION: +;example.com. IN A + +;; ANSWER SECTION: +example.com. 53 IN A 104.18.27.120 +example.com. 53 IN A 104.18.26.120 +``` + +That's the human-readable version. But what's actually on the wire? A DNS query for `example.com A` is just 29 bytes: + +``` + ID Flags QCount ACount NSCount ARCount + ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ +Header: AB CD 01 00 00 01 00 00 00 00 00 00 + └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ + ↑ ↑ ↑ + │ │ └─ 1 question, 0 answers, 0 authority, 0 additional + │ └─ Standard query, recursion desired + └─ Random ID (we'll match this in the response) + +Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01 + ── ───────────────────── ── ───────── ── ───── ───── + 7 e x a m p l e 3 c o m end A IN + ↑ ↑ ↑ + └─ length prefix └─ length └─ root label (end of name) +``` + +12 bytes of header + 17 bytes of question = 29 bytes to ask "what's the IP for example.com?" Compare that to an HTTP request for the same information — you'd need hundreds of bytes just for headers. + +We can send exactly those bytes and capture what comes back: + +```python +python3 -c " +import socket +# Hand-craft a DNS query: header (12 bytes) + question (17 bytes) +q = b'\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00' # header +q += b'\x07example\x03com\x00\x00\x01\x00\x01' # question +s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) +s.sendto(q, ('127.0.0.1', 53)) +resp = s.recv(512) +for i in range(0, len(resp), 16): + h = ' '.join(f'{b:02x}' for b in resp[i:i+16]) + a = ''.join(chr(b) if 32<=b<127 else '.' for b in resp[i:i+16]) + print(f'{i:08x} {h:<48s} {a}') +" +``` + +``` +00000000 ab cd 81 80 00 01 00 02 00 00 00 00 07 65 78 61 .............exa +00000010 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 07 65 78 mple.com......ex +00000020 61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 00 00 ample.com....... +00000030 00 19 00 04 68 12 1b 78 07 65 78 61 6d 70 6c 65 ....h..x.example +00000040 03 63 6f 6d 00 00 01 00 01 00 00 00 19 00 04 68 .com...........h +00000050 12 1a 78 ..x +``` + +83 bytes back. Let's annotate the response: + +``` + ID Flags QCount ACount NSCount ARCount + ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ +Header: AB CD 81 80 00 01 00 02 00 00 00 00 + └────┘ └────┘ └────┘ └────┘ └────┘ └────┘ + ↑ ↑ ↑ ↑ + │ │ │ └─ 2 answers + │ │ └─ 1 question (echoed back) + │ └─ Response flag set, recursion available + └─ Same ID as our query + +Question: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01 + (same as our query — echoed back) + +Answer 1: 07 65 78 61 6D 70 6C 65 03 63 6F 6D 00 00 01 00 01 + ───────────────────────────────────── ── ───── ───── + e x a m p l e . c o m end A IN + + 00 00 00 19 00 04 68 12 1B 78 + ─────────── ───── ─────────── + TTL: 25s len:4 104.18.27.120 + +Answer 2: (same domain repeated) 00 01 00 01 00 00 00 19 00 04 68 12 1A 78 + ─────────── + 104.18.26.120 +``` + +Notice something wasteful? The domain `example.com` appears *three times* — once in the question, twice in the answers. That's 39 bytes of repeated names in an 83-byte packet. DNS has a solution for this — but first, the overall structure. + +The whole thing fits in a single UDP datagram. The structure is: + +``` ++--+--+--+--+--+--+--+--+ +| Header | 12 bytes: ID, flags, counts ++--+--+--+--+--+--+--+--+ +| Questions | What you're asking ++--+--+--+--+--+--+--+--+ +| Answers | The response records ++--+--+--+--+--+--+--+--+ +| Authorities | NS records for the zone ++--+--+--+--+--+--+--+--+ +| Additional | Extra helpful records ++--+--+--+--+--+--+--+--+ +``` + +In Rust, parsing the header is just reading 12 bytes and unpacking the flags: + +```rust +pub fn read(buffer: &mut BytePacketBuffer) -> Result { + let id = buffer.read_u16()?; + let flags = buffer.read_u16()?; + // Flags pack 9 fields into 16 bits + let recursion_desired = (flags & (1 << 8)) > 0; + let truncated_message = (flags & (1 << 9)) > 0; + let authoritative_answer = (flags & (1 << 10)) > 0; + let opcode = (flags >> 11) & 0x0F; + let response = (flags & (1 << 15)) > 0; + // ... and so on +} +``` + +No padding, no alignment, no JSON overhead. DNS was designed in 1987 when every byte counted, and honestly? The wire format is kind of beautiful in its efficiency. + +## Label compression is the clever part + +Remember how `example.com` appeared three times in that 83-byte response? Domain names in DNS are stored as a sequence of **labels** — length-prefixed segments: + +``` +example.com → [7]example[3]com[0] +``` + +The `[7]` means "the next 7 bytes are a label." The `[0]` is the root label (end of name). That's 13 bytes per occurrence, 39 bytes for three repetitions. In a response with authority and additional records, domain names can account for half the packet. + +DNS solves this with **compression pointers** — if the top two bits of a length byte are `11`, the remaining 14 bits are an offset back into the packet where the rest of the name can be found. A well-compressed version of our response would replace the answer names with `C0 0C` — a 2-byte pointer to offset 12 where `example.com` first appears in the question section. That turns 39 bytes of names into 15 (13 + 2 + 2). Our upstream didn't bother compressing, but many do — especially when related domains appear: + +``` +Offset 0x20: [6]google[3]com[0] ← full name +Offset 0x40: [4]mail[0xC0][0x20] ← "mail" + pointer to offset 0x20 +Offset 0x50: [3]www[0xC0][0x20] ← "www" + pointer to offset 0x20 +``` + +Pointers can chain — a pointer can point to another pointer. Parsing this correctly requires tracking your position in the buffer and handling jumps: + +```rust +pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> { + let mut pos = self.pos(); + let mut jumped = false; + let mut delim = ""; + + loop { + let len = self.get(pos)?; + + // Top two bits set = compression pointer + if (len & 0xC0) == 0xC0 { + if !jumped { + self.seek(pos + 2)?; // advance past the pointer + } + let offset = (((len as u16) ^ 0xC0) << 8) | self.get(pos + 1)? as u16; + pos = offset as usize; + jumped = true; + continue; + } + + pos += 1; + if len == 0 { break; } // root label + + outstr.push_str(delim); + outstr.push_str(&self.get_range(pos, len as usize)? + .iter().map(|&b| b as char).collect::()); + delim = "."; + pos += len as usize; + } + + if !jumped { + self.seek(pos)?; + } + Ok(()) +} +``` + +This one bit me: when you follow a pointer, you must *not* advance the buffer's read position past where you jumped from. The pointer is 2 bytes, so you advance by 2, but the actual label data lives elsewhere in the packet. If you follow the pointer and also advance past it, you'll skip over the next record entirely. I spent a fun evening debugging that one. + +## TTL adjustment on read, not write + +This is my favorite trick in the whole codebase. I initially stored the remaining TTL and decremented it, which meant I needed a background thread to sweep expired entries. It worked, but it felt wrong — too much machinery for something simple. + +The cleaner approach: store the original TTL and the timestamp when the record was cached. On read, compute `remaining = original_ttl - elapsed`. If it's zero or negative, the entry is stale — evict it lazily. + +```rust +pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option { + let key = (domain.to_lowercase(), qtype); + let entry = self.entries.get(&key)?; + let elapsed = entry.cached_at.elapsed().as_secs() as u32; + + if elapsed >= entry.original_ttl { + self.entries.remove(&key); + return None; + } + + // Adjust TTLs in the response to reflect remaining time + let mut packet = entry.packet.clone(); + for answer in &mut packet.answers { + answer.set_ttl(entry.original_ttl.saturating_sub(elapsed)); + } + Some(packet) +} +``` + +No background thread. No timer. Entries expire lazily. The cache stays consistent because every consumer sees the adjusted TTL. + +## Async per-query with tokio + +Each incoming UDP packet spawns a tokio task. The main loop never blocks: + +```rust +loop { + let mut buffer = BytePacketBuffer::new(); + let (_, src_addr) = socket.recv_from(&mut buffer.buf).await?; + + let ctx = Arc::clone(&ctx); + tokio::spawn(async move { + if let Err(e) = handle_query(buffer, src_addr, &ctx).await { + error!("{} | HANDLER ERROR | {}", src_addr, e); + } + }); +} +``` + +Each `handle_query` walks a pipeline. This is the part where "from scratch" pays off — every step is just a function that either returns a response or says "not my problem, pass it on": + +``` + ┌─────────────────────────────────────────────────────┐ + │ Numa Resolution Pipeline │ + └─────────────────────────────────────────────────────┘ + + Query ──→ Overrides ──→ .numa TLD ──→ Blocklist ──→ Zones ──→ Cache ──→ DoH + │ │ │ │ │ │ │ + │ │ match? │ match? │ blocked? │ match? │ hit? │ + │ ↓ ↓ ↓ ↓ ↓ ↓ + │ respond respond 0.0.0.0 respond respond forward + │ (auto-reverts (reverse (ad gone) (static (TTL to upstream + │ after N min) proxy+TLS) records) adjusted) (encrypted) + │ + └──→ Each step either answers or passes to the next. + Adding a feature = inserting a function into this chain. +``` + +Want conditional forwarding for Tailscale? Insert a step before the upstream that checks the domain suffix. Want to override `api.example.com` for 5 minutes while debugging? Insert an entry in the overrides step — it auto-expires and the domain goes back to resolving normally. A DNS library would have hidden this pipeline behind an opaque `resolve()` call. + +This is one of those cases where Rust + tokio makes things almost embarrassingly simple. In a synchronous resolver, you'd need a thread pool or hand-rolled event loop. Here, each query is a lightweight future. A slow upstream query doesn't block anything — other queries keep flowing. + +## DNS-over-HTTPS: the "wait, that's it?" moment + +The most recent addition, and honestly the one that surprised me with how little code it needed. DoH (RFC 8484) is conceptually simple: take the exact same DNS wire-format packet you'd send over UDP, POST it to an HTTPS endpoint with `Content-Type: application/dns-message`, and parse the response the same way. Same bytes, different transport. + +```rust +async fn forward_doh( + query: &DnsPacket, + url: &str, + client: &reqwest::Client, + timeout_duration: Duration, +) -> Result { + let mut send_buffer = BytePacketBuffer::new(); + query.write(&mut send_buffer)?; + + let resp = timeout(timeout_duration, client + .post(url) + .header("content-type", "application/dns-message") + .header("accept", "application/dns-message") + .body(send_buffer.filled().to_vec()) + .send()) + .await??.error_for_status()?; + + let bytes = resp.bytes().await?; + let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes); + DnsPacket::from_buffer(&mut recv_buffer) +} +``` + +The one gotcha that cost me an hour: Quad9 and other DoH providers require HTTP/2. My first attempt used HTTP/1.1 and got a cryptic 400 Bad Request. Adding the `http2` feature to reqwest fixed it. The upside of HTTP/2? Connection multiplexing means subsequent queries reuse the TLS session — ~16ms vs ~50ms for the first query. Free performance. + +The `Upstream` enum dispatches between UDP and DoH based on the URL scheme: + +```rust +pub enum Upstream { + Udp(SocketAddr), + Doh { url: String, client: reqwest::Client }, +} +``` + +If the configured address starts with `https://`, it's DoH. Otherwise, plain UDP. Simple, no toggles. + +## "Why not just use dnsmasq + nginx + mkcert?" + +Fair question — I got this a lot when I first [posted about Numa](https://www.reddit.com/r/programare/). And the answer is: you absolutely can. Those are mature, battle-tested tools. + +The difference is integration. With dnsmasq + nginx + mkcert, you're configuring three tools: DNS resolution, reverse proxy rules, and certificate generation. Each has its own config format, its own lifecycle, its own failure modes. Numa puts the DNS record, the reverse proxy, and the TLS cert behind a single API call: + +```bash +curl -X POST localhost:5380/services -d '{"name":"frontend","target_port":5173}' +``` + +That creates the DNS entry, generates a TLS certificate with the correct SAN, and starts proxying — including WebSocket upgrade for Vite HMR. One command, no config files. + +There's also a distinction people miss: **mkcert and certbot solve different problems.** Certbot issues certificates for public domains via Let's Encrypt — it needs DNS validation or an open port 80. Numa generates certificates for `.numa` domains that don't exist publicly. You can't get a Let's Encrypt cert for `frontend.numa`. They're complementary, not alternatives. + +Someone on Reddit told me the real value is "TLS termination + reverse proxy, simple to install, for developers — stop there." Honestly, they might be right about focus. But DNS is the foundation the proxy sits on, and having full control over the resolution pipeline is what makes auto-revert overrides and LAN discovery possible. Sometimes the "unnecessary" part is what makes the interesting part work. + +## The blocklist memory problem + +Numa's ad blocking loads the [Hagezi Pro](https://github.com/hagezi/dns-blocklists) list at startup — ~385,000 domains stored in a `HashSet`. This works, but it consumes ~30MB of memory. For a laptop DNS proxy, that's fine. For embedded devices or a future where you want to run Numa on a router, it's too much. + +The obvious optimization is a **Bloom filter** — a probabilistic data structure that can tell you "definitely not in the set" or "probably in the set" using a fraction of the memory. A Bloom filter for 385K domains with a 0.1% false positive rate would use ~700KB instead of 30MB. The false positives (0.1% of queries hitting domains not in the list) would be blocked unnecessarily, which is acceptable for ad blocking. + +I haven't implemented this yet — the `HashSet` is simple, correct, and 30MB is nothing on a laptop. But if Numa ever needs to run on a router or a Raspberry Pi, this is the first optimization I'd reach for. + +## What I learned + +**DNS is a 40-year-old protocol that works remarkably well.** The wire format is tight, the caching model is elegant, and the hierarchical delegation system has scaled to billions of queries per day. The things people complain about (DNSSEC complexity, lack of encryption) are extensions bolted on decades later, not flaws in the original design. + +**"From scratch" gives you full control.** When I wanted to add ephemeral overrides that auto-revert, it was trivial — just a new step in the resolution pipeline. Conditional forwarding for Tailscale/VPN? Another step. Every feature is a function that takes a query and returns either a response or "pass to the next stage." A DNS library would have hidden this pipeline. + +**The hard parts aren't where you'd expect.** Parsing the wire protocol was straightforward (RFC 1035 is well-written). The hard parts were: browsers rejecting wildcard certs under single-label TLDs (`*.numa` fails — you need per-service SANs), macOS resolver quirks (scutil vs /etc/resolv.conf), and getting multiple processes to bind the same multicast port (`SO_REUSEPORT` on macOS, `SO_REUSEADDR` on Linux). + +**Terminology will get you roasted.** I initially called Numa a "DNS resolver" and got corrected on Reddit — it's a forwarding resolver (DNS proxy). It doesn't walk the delegation chain from root servers; it forwards to an upstream. The distinction matters to people who work with DNS for a living, and being sloppy about it cost me credibility in my first community posts. If you're building in a domain with established terminology, learn the vocabulary before you show up. + +## What's next + +Numa is at v0.5.0 with DNS forwarding, caching, ad blocking, DNS-over-HTTPS, .numa local domains with auto TLS, and LAN service discovery. + +On the roadmap: + +- **DoT (DNS-over-TLS)** — DoH was first because it passes through captive portals and corporate firewalls (port 443 vs 853). DoT has less framing overhead, so it's faster. Both will be available. +- **Recursive resolution** — walk the delegation chain from root servers instead of forwarding. Combined with DNSSEC validation, this removes the need to trust any upstream resolver. +- **[pkarr](https://github.com/pubky/pkarr) integration** — self-sovereign DNS via the Mainline BitTorrent DHT. Publish DNS records signed with your Ed25519 key, no registrar needed. + +But those are rabbit holes for future posts. + +[github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) diff --git a/site/CNAME b/site/CNAME new file mode 100644 index 0000000..3004d18 --- /dev/null +++ b/site/CNAME @@ -0,0 +1 @@ +numa.rs \ No newline at end of file diff --git a/site/blog-template.html b/site/blog-template.html new file mode 100644 index 0000000..61bdb3b --- /dev/null +++ b/site/blog-template.html @@ -0,0 +1,303 @@ + + + + + +$title$ — Numa + + + + + + + + + + + + + + + + diff --git a/site/blog/dns-from-scratch.html b/site/blog/dns-from-scratch.html new file mode 100644 index 0000000..affdfbf --- /dev/null +++ b/site/blog/dns-from-scratch.html @@ -0,0 +1,717 @@ + + + + + +I Built a DNS Resolver from Scratch in Rust — Numa + + + + + + + + + + +
+
+

I Built a DNS Resolver from Scratch in Rust

+ +
+ +

I wanted to understand how DNS actually works. Not the “it translates +domain names to IP addresses” explanation — the actual bytes on the +wire. What does a DNS packet look like? How does label compression work? +Why is everything crammed into 512 bytes?

+

So I built one from scratch in Rust. No hickory-dns, no +trust-dns, no simple-dns. The entire RFC 1035 +wire protocol — headers, labels, compression pointers, record types — +parsed and serialized by hand. It started as a weekend learning project, +became a side project I kept coming back to over 6 years, and eventually +turned into Numa — +which I now use as my actual system DNS.

+

A note on terminology before we go further: Numa is currently a +forwarding resolver — it parses and caches DNS packets, but +forwards queries to an upstream (Quad9, Cloudflare, or any DoH provider) +rather than walking the delegation chain from root servers itself. Think +of it as a smart proxy that does useful things with your DNS traffic +locally (caching, ad blocking, overrides, local service domains) before +forwarding what it can’t answer. Full recursive resolution — where Numa +talks directly to root and authoritative nameservers — is on the +roadmap, along with DNSSEC validation.

+

Here’s what surprised me along the way.

+

What does a DNS +packet actually look like?

+

You can see a real one yourself. Run this:

+
dig @127.0.0.1 example.com A +noedns
+
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 15242
+;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 0
+
+;; QUESTION SECTION:
+;example.com.                   IN      A
+
+;; ANSWER SECTION:
+example.com.            53      IN      A       104.18.27.120
+example.com.            53      IN      A       104.18.26.120
+

That’s the human-readable version. But what’s actually on the wire? A +DNS query for example.com A is just 29 bytes:

+
         ID    Flags  QCount ACount NSCount ARCount
+        ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
+Header: AB CD  01 00  00 01  00 00  00 00  00 00
+        └────┘ └────┘ └────┘ └────┘ └────┘ └────┘
+         ↑      ↑      ↑
+         │      │      └─ 1 question, 0 answers, 0 authority, 0 additional
+         │      └─ Standard query, recursion desired
+         └─ Random ID (we'll match this in the response)
+
+Question: 07 65 78 61 6D 70 6C 65  03 63 6F 6D  00  00 01  00 01
+          ── ─────────────────────  ── ─────────  ──  ─────  ─────
+          7  e  x  a  m  p  l  e   3  c  o  m   end  A      IN
+          ↑                        ↑             ↑
+          └─ length prefix         └─ length     └─ root label (end of name)
+

12 bytes of header + 17 bytes of question = 29 bytes to ask “what’s +the IP for example.com?” Compare that to an HTTP request for the same +information — you’d need hundreds of bytes just for headers.

+

We can send exactly those bytes and capture what comes back:

+
python3 -c "
+import socket
+# Hand-craft a DNS query: header (12 bytes) + question (17 bytes)
+q  = b'\xab\xcd\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00'  # header
+q += b'\x07example\x03com\x00\x00\x01\x00\x01'              # question
+s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+s.sendto(q, ('127.0.0.1', 53))
+resp = s.recv(512)
+for i in range(0, len(resp), 16):
+    h = ' '.join(f'{b:02x}' for b in resp[i:i+16])
+    a = ''.join(chr(b) if 32<=b<127 else '.' for b in resp[i:i+16])
+    print(f'{i:08x}  {h:<48s}  {a}')
+"
+
00000000  ab cd 81 80 00 01 00 02 00 00 00 00 07 65 78 61   .............exa
+00000010  6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 07 65 78   mple.com......ex
+00000020  61 6d 70 6c 65 03 63 6f 6d 00 00 01 00 01 00 00   ample.com.......
+00000030  00 19 00 04 68 12 1b 78 07 65 78 61 6d 70 6c 65   ....h..x.example
+00000040  03 63 6f 6d 00 00 01 00 01 00 00 00 19 00 04 68   .com...........h
+00000050  12 1a 78                                          ..x
+

83 bytes back. Let’s annotate the response:

+
         ID    Flags  QCount ACount NSCount ARCount
+        ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐
+Header: AB CD  81 80  00 01  00 02  00 00  00 00
+        └────┘ └────┘ └────┘ └────┘ └────┘ └────┘
+         ↑      ↑      ↑      ↑
+         │      │      │      └─ 2 answers
+         │      │      └─ 1 question (echoed back)
+         │      └─ Response flag set, recursion available
+         └─ Same ID as our query
+
+Question: 07 65 78 61 6D 70 6C 65  03 63 6F 6D  00  00 01  00 01
+          (same as our query — echoed back)
+
+Answer 1: 07 65 78 61 6D 70 6C 65  03 63 6F 6D  00  00 01  00 01
+          ─────────────────────────────────────  ──  ─────  ─────
+          e  x  a  m  p  l  e  .  c  o  m       end  A      IN
+
+          00 00 00 19  00 04  68 12 1B 78
+          ───────────  ─────  ───────────
+          TTL: 25s     len:4  104.18.27.120
+
+Answer 2: (same domain repeated)  00 01  00 01  00 00 00 19  00 04  68 12 1A 78
+                                                                    ───────────
+                                                                    104.18.26.120
+

Notice something wasteful? The domain example.com +appears three times — once in the question, twice in the +answers. That’s 39 bytes of repeated names in an 83-byte packet. DNS has +a solution for this — but first, the overall structure.

+

The whole thing fits in a single UDP datagram. The structure is:

+
+--+--+--+--+--+--+--+--+
+|         Header         |  12 bytes: ID, flags, counts
++--+--+--+--+--+--+--+--+
+|        Questions       |  What you're asking
++--+--+--+--+--+--+--+--+
+|         Answers        |  The response records
++--+--+--+--+--+--+--+--+
+|       Authorities      |  NS records for the zone
++--+--+--+--+--+--+--+--+
+|       Additional       |  Extra helpful records
++--+--+--+--+--+--+--+--+
+

In Rust, parsing the header is just reading 12 bytes and unpacking +the flags:

+
pub fn read(buffer: &mut BytePacketBuffer) -> Result<DnsHeader> {
+    let id = buffer.read_u16()?;
+    let flags = buffer.read_u16()?;
+    // Flags pack 9 fields into 16 bits
+    let recursion_desired = (flags & (1 << 8)) > 0;
+    let truncated_message = (flags & (1 << 9)) > 0;
+    let authoritative_answer = (flags & (1 << 10)) > 0;
+    let opcode = (flags >> 11) & 0x0F;
+    let response = (flags & (1 << 15)) > 0;
+    // ... and so on
+}
+

No padding, no alignment, no JSON overhead. DNS was designed in 1987 +when every byte counted, and honestly? The wire format is kind of +beautiful in its efficiency.

+

Label compression is the +clever part

+

Remember how example.com appeared three times in that +83-byte response? Domain names in DNS are stored as a sequence of +labels — length-prefixed segments:

+
example.com → [7]example[3]com[0]
+

The [7] means “the next 7 bytes are a label.” The +[0] is the root label (end of name). That’s 13 bytes per +occurrence, 39 bytes for three repetitions. In a response with authority +and additional records, domain names can account for half the +packet.

+

DNS solves this with compression pointers — if the +top two bits of a length byte are 11, the remaining 14 bits +are an offset back into the packet where the rest of the name can be +found. A well-compressed version of our response would replace the +answer names with C0 0C — a 2-byte pointer to offset 12 +where example.com first appears in the question section. +That turns 39 bytes of names into 15 (13 + 2 + 2). Our upstream didn’t +bother compressing, but many do — especially when related domains +appear:

+
Offset 0x20: [6]google[3]com[0]        ← full name
+Offset 0x40: [4]mail[0xC0][0x20]       ← "mail" + pointer to offset 0x20
+Offset 0x50: [3]www[0xC0][0x20]        ← "www" + pointer to offset 0x20
+

Pointers can chain — a pointer can point to another pointer. Parsing +this correctly requires tracking your position in the buffer and +handling jumps:

+
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
+    let mut pos = self.pos();
+    let mut jumped = false;
+    let mut delim = "";
+
+    loop {
+        let len = self.get(pos)?;
+
+        // Top two bits set = compression pointer
+        if (len & 0xC0) == 0xC0 {
+            if !jumped {
+                self.seek(pos + 2)?; // advance past the pointer
+            }
+            let offset = (((len as u16) ^ 0xC0) << 8) | self.get(pos + 1)? as u16;
+            pos = offset as usize;
+            jumped = true;
+            continue;
+        }
+
+        pos += 1;
+        if len == 0 { break; } // root label
+
+        outstr.push_str(delim);
+        outstr.push_str(&self.get_range(pos, len as usize)?
+            .iter().map(|&b| b as char).collect::<String>());
+        delim = ".";
+        pos += len as usize;
+    }
+
+    if !jumped {
+        self.seek(pos)?;
+    }
+    Ok(())
+}
+

This one bit me: when you follow a pointer, you must not +advance the buffer’s read position past where you jumped from. The +pointer is 2 bytes, so you advance by 2, but the actual label data lives +elsewhere in the packet. If you follow the pointer and also advance past +it, you’ll skip over the next record entirely. I spent a fun evening +debugging that one.

+

TTL adjustment on read, not +write

+

This is my favorite trick in the whole codebase. I initially stored +the remaining TTL and decremented it, which meant I needed a background +thread to sweep expired entries. It worked, but it felt wrong — too much +machinery for something simple.

+

The cleaner approach: store the original TTL and the timestamp when +the record was cached. On read, compute +remaining = original_ttl - elapsed. If it’s zero or +negative, the entry is stale — evict it lazily.

+
pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
+    let key = (domain.to_lowercase(), qtype);
+    let entry = self.entries.get(&key)?;
+    let elapsed = entry.cached_at.elapsed().as_secs() as u32;
+
+    if elapsed >= entry.original_ttl {
+        self.entries.remove(&key);
+        return None;
+    }
+
+    // Adjust TTLs in the response to reflect remaining time
+    let mut packet = entry.packet.clone();
+    for answer in &mut packet.answers {
+        answer.set_ttl(entry.original_ttl.saturating_sub(elapsed));
+    }
+    Some(packet)
+}
+

No background thread. No timer. Entries expire lazily. The cache +stays consistent because every consumer sees the adjusted TTL.

+

Async per-query with tokio

+

Each incoming UDP packet spawns a tokio task. The main loop never +blocks:

+
loop {
+    let mut buffer = BytePacketBuffer::new();
+    let (_, src_addr) = socket.recv_from(&mut buffer.buf).await?;
+
+    let ctx = Arc::clone(&ctx);
+    tokio::spawn(async move {
+        if let Err(e) = handle_query(buffer, src_addr, &ctx).await {
+            error!("{} | HANDLER ERROR | {}", src_addr, e);
+        }
+    });
+}
+

Each handle_query walks a pipeline. This is the part +where “from scratch” pays off — every step is just a function that +either returns a response or says “not my problem, pass it on”:

+
                     ┌─────────────────────────────────────────────────────┐
+                     │              Numa Resolution Pipeline               │
+                     └─────────────────────────────────────────────────────┘
+
+  Query ──→ Overrides ──→ .numa TLD ──→ Blocklist ──→ Zones ──→ Cache ──→ DoH
+    │        │              │             │             │         │         │
+    │        │ match?       │ match?      │ blocked?    │ match?  │ hit?    │
+    │        ↓              ↓             ↓             ↓         ↓         ↓
+    │      respond        respond       0.0.0.0      respond   respond   forward
+    │      (auto-reverts  (reverse      (ad gone)    (static   (TTL      to upstream
+    │       after N min)   proxy+TLS)                 records)  adjusted) (encrypted)
+    │
+    └──→ Each step either answers or passes to the next.
+         Adding a feature = inserting a function into this chain.
+

Want conditional forwarding for Tailscale? Insert a step before the +upstream that checks the domain suffix. Want to override +api.example.com for 5 minutes while debugging? Insert an +entry in the overrides step — it auto-expires and the domain goes back +to resolving normally. A DNS library would have hidden this pipeline +behind an opaque resolve() call.

+

This is one of those cases where Rust + tokio makes things almost +embarrassingly simple. In a synchronous resolver, you’d need a thread +pool or hand-rolled event loop. Here, each query is a lightweight +future. A slow upstream query doesn’t block anything — other queries +keep flowing.

+

DNS-over-HTTPS: the +“wait, that’s it?” moment

+

The most recent addition, and honestly the one that surprised me with +how little code it needed. DoH (RFC 8484) is conceptually simple: take +the exact same DNS wire-format packet you’d send over UDP, POST it to an +HTTPS endpoint with Content-Type: application/dns-message, +and parse the response the same way. Same bytes, different +transport.

+
async fn forward_doh(
+    query: &DnsPacket,
+    url: &str,
+    client: &reqwest::Client,
+    timeout_duration: Duration,
+) -> Result<DnsPacket> {
+    let mut send_buffer = BytePacketBuffer::new();
+    query.write(&mut send_buffer)?;
+
+    let resp = timeout(timeout_duration, client
+        .post(url)
+        .header("content-type", "application/dns-message")
+        .header("accept", "application/dns-message")
+        .body(send_buffer.filled().to_vec())
+        .send())
+    .await??.error_for_status()?;
+
+    let bytes = resp.bytes().await?;
+    let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes);
+    DnsPacket::from_buffer(&mut recv_buffer)
+}
+

The one gotcha that cost me an hour: Quad9 and other DoH providers +require HTTP/2. My first attempt used HTTP/1.1 and got a cryptic 400 Bad +Request. Adding the http2 feature to reqwest fixed it. The +upside of HTTP/2? Connection multiplexing means subsequent queries reuse +the TLS session — ~16ms vs ~50ms for the first query. Free +performance.

+

The Upstream enum dispatches between UDP and DoH based +on the URL scheme:

+
pub enum Upstream {
+    Udp(SocketAddr),
+    Doh { url: String, client: reqwest::Client },
+}
+

If the configured address starts with https://, it’s +DoH. Otherwise, plain UDP. Simple, no toggles.

+

“Why not just use dnsmasq ++ nginx + mkcert?”

+

Fair question — I got this a lot when I first posted about Numa. And +the answer is: you absolutely can. Those are mature, battle-tested +tools.

+

The difference is integration. With dnsmasq + nginx + mkcert, you’re +configuring three tools: DNS resolution, reverse proxy rules, and +certificate generation. Each has its own config format, its own +lifecycle, its own failure modes. Numa puts the DNS record, the reverse +proxy, and the TLS cert behind a single API call:

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

That creates the DNS entry, generates a TLS certificate with the +correct SAN, and starts proxying — including WebSocket upgrade for Vite +HMR. One command, no config files.

+

There’s also a distinction people miss: mkcert and certbot +solve different problems. Certbot issues certificates for +public domains via Let’s Encrypt — it needs DNS validation or an open +port 80. Numa generates certificates for .numa domains that +don’t exist publicly. You can’t get a Let’s Encrypt cert for +frontend.numa. They’re complementary, not alternatives.

+

Someone on Reddit told me the real value is “TLS termination + +reverse proxy, simple to install, for developers — stop there.” +Honestly, they might be right about focus. But DNS is the foundation the +proxy sits on, and having full control over the resolution pipeline is +what makes auto-revert overrides and LAN discovery possible. Sometimes +the “unnecessary” part is what makes the interesting part work.

+

The blocklist memory problem

+

Numa’s ad blocking loads the Hagezi Pro list at +startup — ~385,000 domains stored in a +HashSet<String>. This works, but it consumes ~30MB of +memory. For a laptop DNS proxy, that’s fine. For embedded devices or a +future where you want to run Numa on a router, it’s too much.

+

The obvious optimization is a Bloom filter — a +probabilistic data structure that can tell you “definitely not in the +set” or “probably in the set” using a fraction of the memory. A Bloom +filter for 385K domains with a 0.1% false positive rate would use ~700KB +instead of 30MB. The false positives (0.1% of queries hitting domains +not in the list) would be blocked unnecessarily, which is acceptable for +ad blocking.

+

I haven’t implemented this yet — the HashSet is simple, +correct, and 30MB is nothing on a laptop. But if Numa ever needs to run +on a router or a Raspberry Pi, this is the first optimization I’d reach +for.

+

What I learned

+

DNS is a 40-year-old protocol that works remarkably +well. The wire format is tight, the caching model is elegant, +and the hierarchical delegation system has scaled to billions of queries +per day. The things people complain about (DNSSEC complexity, lack of +encryption) are extensions bolted on decades later, not flaws in the +original design.

+

“From scratch” gives you full control. When I wanted +to add ephemeral overrides that auto-revert, it was trivial — just a new +step in the resolution pipeline. Conditional forwarding for +Tailscale/VPN? Another step. Every feature is a function that takes a +query and returns either a response or “pass to the next stage.” A DNS +library would have hidden this pipeline.

+

The hard parts aren’t where you’d expect. Parsing +the wire protocol was straightforward (RFC 1035 is well-written). The +hard parts were: browsers rejecting wildcard certs under single-label +TLDs (*.numa fails — you need per-service SANs), macOS +resolver quirks (scutil vs /etc/resolv.conf), and getting multiple +processes to bind the same multicast port (SO_REUSEPORT on +macOS, SO_REUSEADDR on Linux).

+

Terminology will get you roasted. I initially called +Numa a “DNS resolver” and got corrected on Reddit — it’s a forwarding +resolver (DNS proxy). It doesn’t walk the delegation chain from root +servers; it forwards to an upstream. The distinction matters to people +who work with DNS for a living, and being sloppy about it cost me +credibility in my first community posts. If you’re building in a domain +with established terminology, learn the vocabulary before you show +up.

+

What’s next

+

Numa is at v0.5.0 with DNS forwarding, caching, ad blocking, +DNS-over-HTTPS, .numa local domains with auto TLS, and LAN service +discovery.

+

On the roadmap:

+
    +
  • DoT (DNS-over-TLS) — DoH was first because it +passes through captive portals and corporate firewalls (port 443 vs +853). DoT has less framing overhead, so it’s faster. Both will be +available.
  • +
  • Recursive resolution — walk the delegation chain +from root servers instead of forwarding. Combined with DNSSEC +validation, this removes the need to trust any upstream resolver.
  • +
  • pkarr +integration — self-sovereign DNS via the Mainline BitTorrent +DHT. Publish DNS records signed with your Ed25519 key, no registrar +needed.
  • +
+

But those are rabbit holes for future posts.

+

github.com/razvandimescu/numa

+
+ + + + + diff --git a/site/blog/index.html b/site/blog/index.html new file mode 100644 index 0000000..354dd31 --- /dev/null +++ b/site/blog/index.html @@ -0,0 +1,188 @@ + + + + + +Blog — Numa + + + + + + + + + + +
+

Blog

+ +
+ + + + + diff --git a/site/index.html b/site/index.html index 7982daf..8a9361f 100644 --- a/site/index.html +++ b/site/index.html @@ -3,8 +3,13 @@ -Numa — DNS that governs itself +Numa — DNS you own. Everywhere you go. + + + + + @@ -785,6 +790,169 @@ p.lead { background: rgba(82, 122, 82, 0.04); } +/* =========================== + PERFORMANCE + =========================== */ +.perf-section { + background: var(--bg-surface); +} + +.perf-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3rem; + margin-top: 3rem; + align-items: start; +} + +.perf-table-wrapper { + overflow-x: auto; + border: 1px solid var(--border); +} + +.perf-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; + min-width: 380px; +} + +.perf-table thead th { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); + padding: 0.8rem 1rem; + text-align: right; + border-bottom: 1px solid var(--border); + background: var(--bg-elevated); + font-weight: 500; +} + +.perf-table thead th:first-child { + text-align: left; +} + +.perf-table tbody td { + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border); + color: var(--text-secondary); + text-align: right; + font-family: var(--font-mono); + font-size: 0.82rem; +} + +.perf-table tbody td:first-child { + font-family: var(--font-body); + font-size: 0.85rem; + color: var(--text-primary); + text-align: left; + font-weight: 400; +} + +.perf-table tbody tr:hover { + background: var(--bg-elevated); +} + +.perf-table tbody tr.perf-highlight td { + color: var(--emerald); + font-weight: 500; +} + +.perf-table tbody tr.perf-highlight td:first-child { + color: var(--emerald); +} + +.perf-sidebar { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.perf-stat { + background: var(--bg-card); + border: 1px solid var(--border); + padding: 1.5rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.04); +} + +.perf-stat-value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + line-height: 1.1; +} + +.perf-stat-value.emerald { color: var(--emerald); } +.perf-stat-value.teal { color: var(--teal); } +.perf-stat-value.amber { color: var(--amber); } + +.perf-stat-label { + font-size: 0.82rem; + color: var(--text-secondary); + margin-top: 0.4rem; +} + +.perf-bar-group { + margin-top: 1.5rem; +} + +.perf-bar-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.6rem; +} + +.perf-bar-label { + font-size: 0.75rem; + color: var(--text-secondary); + width: 80px; + flex-shrink: 0; + text-align: right; +} + +.perf-bar-track { + flex: 1; + height: 18px; + background: var(--bg-elevated); + border-radius: 2px; + overflow: hidden; + position: relative; +} + +.perf-bar-fill { + height: 100%; + border-radius: 2px; + transition: width 0.6s ease; +} + +.perf-bar-fill.emerald { background: var(--emerald); } +.perf-bar-fill.teal { background: var(--teal); } +.perf-bar-fill.dim { background: var(--text-dim); } + +.perf-bar-ms { + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text-dim); + width: 42px; + flex-shrink: 0; +} + +.perf-note { + font-size: 0.78rem; + color: var(--text-dim); + margin-top: 2rem; + line-height: 1.6; +} + +.perf-note a { + color: var(--teal-dim); + text-decoration: none; + border-bottom: 1px solid var(--border-teal); +} + /* =========================== TECHNICAL =========================== */ @@ -824,6 +992,8 @@ p.lead { color: var(--text-secondary); overflow-x: auto; position: relative; + white-space: pre-wrap; + word-break: break-all; } .code-block::before { @@ -980,6 +1150,7 @@ footer .closing { .problem-grid { grid-template-columns: 1fr; gap: 2rem; } .layers-grid { grid-template-columns: 1fr; } .tech-grid { grid-template-columns: 1fr; } + .perf-grid { grid-template-columns: 1fr; } .network-grid { grid-template-columns: repeat(2, 1fr); } .network-connections { display: none; } .hero-line { display: none; } @@ -1036,9 +1207,9 @@ footer .closing {
-

Every time you visit a website, you ask a DNS resolver where to go. That resolver sees every domain you visit, when, and how often.

-

Today, a handful of operators control this infrastructure. ICANN governs the root. Registrars can seize domains. Governments compel censorship. Your ISP logs your queries by default.

-

The protocol that underpins the entire internet has no built-in privacy, no cryptographic ownership, and no way for users to choose who they trust.

+

Every time you visit a website, you ask a DNS resolver where to go. That resolver sees every domain you visit, when, and how often. Your ISP logs these queries by default.

+

Ad blockers work in one browser. Pi-hole needs a Raspberry Pi. Your local dev services live at localhost:5173 and you can never remember which port is which.

+

DNS is the foundation of everything you do on the internet, but the tools for controlling it locally are either too complex (dnsmasq + nginx + mkcert) or too limited (cloud-only, appliance-only).

Your browser
@@ -1062,44 +1233,43 @@ footer .closing {
-

Three layers, built incrementally

-

Numa starts as a practical developer tool and evolves toward a decentralized network. Each layer stands on its own.

+

What it does today

+

A portable DNS proxy with ad blocking, encrypted upstream, local service domains, and a REST API. Everything runs in a single binary.

-
Today
-

DNS You Control

+
Layer 1
+

Block & Protect

  • Ad & tracker blocking — 385K+ domains, zero config
  • -
  • Ephemeral DNS overrides with auto-revert
  • -
  • Local service proxy — frontend.numa instead of localhost:5173
  • -
  • Live dashboard with real-time stats and controls
  • -
  • REST API — 22 endpoints for programmatic control
  • +
  • DNS-over-HTTPS — encrypted upstream (Quad9, Cloudflare, any provider)
  • TTL-aware caching (sub-ms lookups)
  • -
  • Single binary, portable — your ad blocker travels with you
  • +
  • Single binary, portable — your DNS travels with you
  • +
  • macOS, Linux, and Windows
-
Next
-

Self-Sovereign DNS

+
Layer 2
+

Developer Tools

    -
  • pkarr integration: Ed25519 keys as domains
  • -
  • Resolve via Mainline BitTorrent DHT (10M+ nodes)
  • -
  • No registrar, no blockchain, no ICANN
  • -
  • Cryptographic verification built-in
  • -
  • Human-readable aliases for pkarr domains
  • +
  • Local service proxy — frontend.numa instead of localhost:5173
  • +
  • Path-based routing — app.numa/api:5001
  • +
  • Ephemeral DNS overrides with auto-revert
  • +
  • LAN service discovery via mDNS
  • +
  • Conditional forwarding — plays nice with Tailscale/VPN split-DNS
  • +
  • REST API — script everything, automate anything
  • +
  • Live dashboard with real-time stats and controls
-
Vision
-

Decentralized Resolver Network

+
Coming Next
+

Self-Sovereign DNS

    -
  • Operators run Numa nodes and stake tokens
  • -
  • Earn rewards for uptime, correctness, latency
  • -
  • Independent auditors send challenge queries
  • -
  • Slashing for NXDOMAIN hijacking or poisoned records
  • -
  • Geographic diversity bonuses
  • -
  • Privacy-preserving resolution (DoH/DoT)
  • +
  • pkarr integration — DNS via Mainline DHT, no registrar needed
  • +
  • Global .numa names — self-publish, DHT-backed
  • +
  • .onion bridge — human-readable names for Tor hidden services
  • +
  • Ed25519 same-key binding — zero new trust assumptions
  • +
  • No blockchain required for core naming
@@ -1131,66 +1301,12 @@ footer .closing {
Cache
-
pkarr / DHT
- -
Upstream
+
DoH Upstream
Respond
-
-

Layered resilience

-
-
-
L4 Permanence
-
Arweave immutable zone snapshots (future)
-
-
-
L3 Distribution
-
Mainline DHT via pkarr — 10M+ nodes
-
-
-
L2 Serving
-
Numa instances worldwide
-
-
-
L1 Compatibility
-
Standard DNS wire protocol — RFC 1035
-
-
-
- -
-

Network actors

-
-
- -

Users

-

Choose resolvers from a decentralized marketplace based on latency, privacy, and reputation

-
-
- -

Operators

-

Stake tokens, run Numa nodes, earn rewards proportional to verified service quality

-
-
- -

Auditors

-

Send challenge queries from diverse locations, verify correctness and latency

-
-
- -

Chain

-

Accounting, reputation scores, reward distribution, slashing proofs

-
-
- -
@@ -1265,6 +1381,22 @@ footer .closing { Yes Real-time + controls + + DNS-over-HTTPS upstream + No + Yes + Yes + No + Built in (HTTP/2 + rustls) + + + Conditional forwarding + No + No + No + Manual + Auto-detects Tailscale/VPN + Zero config needed Complex setup @@ -1273,14 +1405,6 @@ footer .closing { Docker/setup Works out of the box - - Self-sovereign DNS roadmap - No - No - No - No - pkarr / DHT - @@ -1289,6 +1413,125 @@ footer .closing { + +
+
+
+ +

Measured, not claimed

+

Benchmarked with dig against public resolvers on the same machine. Cached queries resolve in under a microsecond.

+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
DNS resolver latency comparison
ResolverAvgP50P99
Numa (cached)<1ms<1ms<1ms
Numa (cold)9ms9ms18ms
System resolver9ms8ms44ms
Quad915ms13ms43ms
Cloudflare19ms14ms132ms
Google22ms17ms37ms
+
+ +
+
+ Numa +
+ <1ms +
+
+ System +
+ 9ms +
+
+ Quad9 +
+ 15ms +
+
+ Cloudflare +
+ 19ms +
+
+ Google +
+ 22ms +
+
+
+ +
+
+
689 ns
+
Cached round-trip — parse query, cache lookup, serialize response
+
+
+
2.0M
+
Queries per second (single-threaded pipeline throughput, batched)
+
+
+
0 allocations
+
Heap allocations in the I/O path — 4KB stack buffers, inline serialization
+
+ +

+ Cold queries match system resolver speed — the bottleneck is upstream RTT, not Numa. We don't claim to be faster when the network is the limit. +

+ Benchmarks are reproducible: cargo bench for micro-benchmarks, python3 bench/dns-bench.sh for end-to-end. + Methodology → +

+
+
+
+
+ + +
@@ -1305,25 +1548,30 @@ footer .closing {
Zero — wire protocol parsed from scratch
Dependencies
-
8 runtime crates (tokio, axum, hyper, serde, serde_json, toml, log, futures)
+
18 runtime crates — tokio, axum, hyper, reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more
Packet Format
RFC 1035 compliant, 4096-byte UDP (EDNS)
Concurrency
-
Arc<ServerCtx> + std::sync::Mutex (sub-µs holds, never across .await)
+
Arc<ServerCtx> + RwLock for reads, Mutex for writes (never across .await)
-
Signatures
-
Ed25519 via pkarr for self-sovereign domains
+
Upstream
+
DNS-over-HTTPS (DoH) via reqwest + http2 + rustls
+# Install (pick one) +$ brew install razvandimescu/tap/numa $ cargo install numa +$ curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh + +# Run $ sudo numa # bind to :53, :80, :5380 $ dig @127.0.0.1 google.com # test resolution -$ open http://numa.numa # dashboard +$ open http://localhost:5380 # dashboard $ curl -X POST localhost:5380/services \ -d '{"name":"frontend", - "target_port":5173}' # http://frontend.numa + "target_port":5173}' # https://frontend.numa
@@ -1345,7 +1593,7 @@ footer .closing {
Phase 1 - Override layer + REST API with 18 endpoints + Override layer + REST API for programmatic DNS control
Phase 2 @@ -1359,25 +1607,21 @@ footer .closing { Phase 4 Local service proxy — .numa domains, HTTP/HTTPS reverse proxy, auto TLS, WebSocket
-
+
Phase 5 - pkarr integration — resolve Ed25519 keys via Mainline DHT (15M nodes) + DNS-over-HTTPS — encrypted upstream, HTTP/2 connection pooling
Phase 6 + pkarr integration — self-sovereign DNS via Mainline DHT, no registrar needed +
+
+ Phase 7 Global .numa names — self-publish, DHT-backed, first-come-first-served
-
- Phase 7 - Audit protocol — challenge-based verification of resolver honesty -
-
+
Phase 8 - Numa Network — proof-of-service consensus, NUMA token, paid .numa domains -
-
- Phase 9 - .onion bridge — human-readable .numa names for Tor hidden services + .onion bridge — human-readable Tor naming via Ed25519 same-key binding
@@ -1391,6 +1635,7 @@ footer .closing {

Built from scratch in Rust. No dependencies on trust.

-- 2.34.1 From 236ef7b4f593d567f89edc908e168d065c7fbf55 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 27 Mar 2026 02:01:08 +0200 Subject: [PATCH 4/5] perf: optimize DNS query hot path (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: optimize hot path — RwLock, inline filtering, pre-allocated strings - Mutex → RwLock for cache, blocklist, and overrides (concurrent read access) - Make cache.lookup() and overrides.lookup() take &self (read-only) - Eliminate 3 Vec allocations per DnsPacket::write() via inline filtering - Pre-allocate domain strings with capacity 64 in parse path - Add criterion micro-benchmarks (hot_path + throughput) - Add bench README documenting both benchmark suites Measured improvement: ~14% faster parsing, ~9% pipeline throughput, round-trip cached 733ns → 698ns (~2.3M queries/sec). Co-Authored-By: Claude Opus 4.6 (1M context) * chore: simplify benchmark code after review - Remove redundant DnsHeader::new() (already set by DnsPacket::new()) - Remove unused DnsHeader import - Change simulate_cached_pipeline to take &DnsCache (lookup is &self now) - Remove unnecessary mut on cache in cache_lookup_miss bench Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- Cargo.lock | 267 ++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 11 ++ Makefile | 13 +- bench/README.md | 87 ++++++++++++++ benches/hot_path.rs | 185 +++++++++++++++++++++++++++++ benches/throughput.rs | 94 +++++++++++++++ src/api.rs | 51 ++++---- src/cache.rs | 18 +-- src/ctx.rs | 16 +-- src/main.rs | 10 +- src/override_store.rs | 7 +- src/packet.rs | 38 +++--- src/record.rs | 8 +- 13 files changed, 728 insertions(+), 77 deletions(-) create mode 100644 bench/README.md create mode 100644 benches/hot_path.rs create mode 100644 benches/throughput.rs diff --git a/Cargo.lock b/Cargo.lock index bd15955..a8563e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -237,6 +243,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.2.57" @@ -261,6 +273,58 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmake" version = "0.1.57" @@ -302,6 +366,73 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "data-encoding" version = "2.10.0" @@ -348,6 +479,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "env_filter" version = "1.0.0" @@ -548,12 +685,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "http" version = "1.4.0" @@ -790,12 +944,32 @@ dependencies = [ "serde", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -971,6 +1145,7 @@ version = "0.5.0" dependencies = [ "arc-swap", "axum", + "criterion", "env_logger", "futures", "http-body-util", @@ -1010,6 +1185,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "pem" version = "3.0.6" @@ -1038,6 +1219,34 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1185,6 +1394,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -1346,6 +1575,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "serde" version = "1.0.228" @@ -1589,6 +1827,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" @@ -1807,6 +2055,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1919,6 +2177,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index fa52afa..ea71da7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,14 @@ time = "0.3" rustls = "0.23" tokio-rustls = "0.26" arc-swap = "1" + +[dev-dependencies] +criterion = { version = "0.5", features = ["html_reports"] } + +[[bench]] +name = "hot_path" +harness = false + +[[bench]] +name = "throughput" +harness = false diff --git a/Makefile b/Makefile index d25d697..643c058 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build lint fmt check audit test clean deploy +.PHONY: all build lint fmt check audit test bench clean deploy blog all: lint build @@ -19,6 +19,17 @@ audit: test: cargo test +bench: + cargo bench + +blog: + @mkdir -p site/blog + @for f in blog/*.md; do \ + name=$$(basename "$$f" .md); \ + pandoc "$$f" --template=site/blog-template.html -o "site/blog/$$name.html"; \ + echo " $$f → site/blog/$$name.html"; \ + done + clean: cargo clean diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..7307369 --- /dev/null +++ b/bench/README.md @@ -0,0 +1,87 @@ +# Benchmarks + +Numa has two benchmark suites measuring different layers of performance. + +## Micro-benchmarks (`benches/`, criterion) + +Nanosecond-precision measurement of individual operations on the hot path. +No running server required — these are pure Rust unit-level benchmarks. + +```sh +cargo bench # run all +cargo bench --bench hot_path # parse, serialize, cache, clone +cargo bench --bench throughput # pipeline QPS, buffer alloc +``` + +### What's measured + +**hot_path** — individual operations: + +| Benchmark | What it measures | +|-----------|-----------------| +| `buffer_parse` | Wire bytes → DnsPacket (typical response with 4 records) | +| `buffer_serialize` | DnsPacket → wire bytes | +| `packet_clone` | Full DnsPacket clone (what cache hit costs) | +| `cache_lookup_hit` | Cache lookup on a single-entry cache | +| `cache_lookup_hit_populated` | Cache lookup with 1000 entries | +| `cache_lookup_miss` | HashMap miss (baseline) | +| `cache_insert` | Insert into cache with packet clone | +| `round_trip_cached` | Full cached path: parse query → cache hit → serialize response | + +**throughput** — pipeline capacity: + +| Benchmark | What it measures | +|-----------|-----------------| +| `pipeline_throughput/N` | N cached queries end-to-end (parse → lookup → serialize) | +| `buffer_alloc` | BytePacketBuffer 4KB zero-init cost | + +### Reading results + +Criterion auto-compares against the previous run: + +``` +round_trip_cached time: [710.5 ns 715.2 ns 720.1 ns] + change: [-2.48% -1.85% -1.21%] (p = 0.00 < 0.05) + Performance has improved. +``` + +- The three values are [lower bound, estimate, upper bound] of the mean +- `change` shows the delta vs the last saved baseline +- HTML reports with charts: `target/criterion/report/index.html` + +To save a named baseline for comparison: + +```sh +cargo bench -- --save-baseline before +# ... make changes ... +cargo bench -- --baseline before +``` + +## End-to-end benchmark (`bench/dns-bench.sh`) + +Real-world latency comparison using `dig` against a running Numa instance +and public resolvers. Measures millisecond-level latency including network I/O. + +```sh +# Start Numa first (default port 15353 for testing) +python3 bench/dns-bench.sh [port] [rounds] +python3 bench/dns-bench.sh 15353 20 # default +``` + +### What's measured + +- **Numa (cold)**: cache flushed before each query — measures upstream forwarding +- **Numa (cached)**: queries hit cache — measures local processing +- **System / Google / Cloudflare / Quad9**: public resolver comparison + +Results saved to `bench/results.json`. + +### When to use which + +| Question | Use | +|----------|-----| +| Did my code change make parsing faster? | `cargo bench --bench hot_path` | +| Is the cached path still sub-microsecond? | `cargo bench --bench hot_path` (round_trip_cached) | +| How many queries/sec can we handle? | `cargo bench --bench throughput` | +| Is Numa still competitive with system resolver? | `bench/dns-bench.sh` | +| Did upstream forwarding regress? | `bench/dns-bench.sh` | diff --git a/benches/hot_path.rs b/benches/hot_path.rs new file mode 100644 index 0000000..accfcf2 --- /dev/null +++ b/benches/hot_path.rs @@ -0,0 +1,185 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use std::net::Ipv4Addr; + +use numa::buffer::BytePacketBuffer; +use numa::cache::DnsCache; +use numa::header::ResultCode; +use numa::packet::DnsPacket; +use numa::question::{DnsQuestion, QueryType}; +use numa::record::DnsRecord; + +fn make_response(domain: &str) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.id = 0x1234; + pkt.header.response = true; + pkt.header.recursion_desired = true; + pkt.header.recursion_available = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + pkt.answers.push(DnsRecord::A { + domain: domain.to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + // Typical response includes authority + additional records + pkt.authorities.push(DnsRecord::NS { + domain: domain.to_string(), + host: format!("ns1.{domain}"), + ttl: 172800, + }); + pkt.authorities.push(DnsRecord::NS { + domain: domain.to_string(), + host: format!("ns2.{domain}"), + ttl: 172800, + }); + pkt.resources.push(DnsRecord::A { + domain: format!("ns1.{domain}"), + addr: Ipv4Addr::new(198, 51, 100, 1), + ttl: 172800, + }); + pkt +} + +fn to_wire(pkt: &DnsPacket) -> Vec { + let mut buf = BytePacketBuffer::new(); + pkt.write(&mut buf).unwrap(); + buf.filled().to_vec() +} + +fn bench_buffer_parse(c: &mut Criterion) { + let pkt = make_response("example.com"); + let wire = to_wire(&pkt); + + c.bench_function("buffer_parse", |b| { + b.iter(|| { + let mut buf = BytePacketBuffer::from_bytes(black_box(&wire)); + DnsPacket::from_buffer(&mut buf).unwrap() + }) + }); +} + +fn bench_buffer_serialize(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("buffer_serialize", |b| { + b.iter(|| { + let mut buf = BytePacketBuffer::new(); + black_box(&pkt).write(&mut buf).unwrap(); + black_box(buf.pos()); + }) + }); +} + +fn bench_packet_clone(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("packet_clone", |b| b.iter(|| black_box(&pkt).clone())); +} + +fn bench_cache_lookup_hit(c: &mut Criterion) { + let mut cache = DnsCache::new(10_000, 60, 86400); + let pkt = make_response("example.com"); + cache.insert("example.com", QueryType::A, &pkt); + + c.bench_function("cache_lookup_hit", |b| { + b.iter(|| { + cache + .lookup(black_box("example.com"), QueryType::A) + .unwrap() + }) + }); +} + +fn bench_cache_lookup_miss(c: &mut Criterion) { + let cache = DnsCache::new(10_000, 60, 86400); + + c.bench_function("cache_lookup_miss", |b| { + b.iter(|| cache.lookup(black_box("nonexistent.com"), QueryType::A)) + }); +} + +fn bench_cache_insert(c: &mut Criterion) { + let pkt = make_response("example.com"); + + c.bench_function("cache_insert", |b| { + let mut cache = DnsCache::new(10_000, 60, 86400); + let mut i = 0u64; + b.iter(|| { + let domain = format!("bench-{i}.example.com"); + cache.insert(&domain, QueryType::A, black_box(&pkt)); + i += 1; + // Reset cache periodically to avoid filling up + if i % 5000 == 0 { + cache.clear(); + } + }) + }); +} + +fn bench_round_trip(c: &mut Criterion) { + // Simulates the cached hot path: parse query → cache hit → serialize response + let query_pkt = { + let mut q = DnsPacket::new(); + q.header.id = 0xABCD; + q.header.recursion_desired = true; + q.questions + .push(DnsQuestion::new("example.com".to_string(), QueryType::A)); + q + }; + let query_wire = to_wire(&query_pkt); + + let response = make_response("example.com"); + let mut cache = DnsCache::new(10_000, 60, 86400); + cache.insert("example.com", QueryType::A, &response); + + c.bench_function("round_trip_cached", |b| { + b.iter(|| { + // 1. Parse incoming query + let mut buf = BytePacketBuffer::from_bytes(black_box(&query_wire)); + let query = DnsPacket::from_buffer(&mut buf).unwrap(); + let qname = &query.questions[0].name; + let qtype = query.questions[0].qtype; + + // 2. Cache lookup + let mut resp = cache.lookup(qname, qtype).unwrap(); + resp.header.id = query.header.id; + + // 3. Serialize response + let mut resp_buf = BytePacketBuffer::new(); + resp.write(&mut resp_buf).unwrap(); + black_box(resp_buf.pos()); + }) + }); +} + +fn bench_cache_populated_lookup(c: &mut Criterion) { + // Benchmark with a realistically populated cache (1000 entries) + let mut cache = DnsCache::new(10_000, 60, 86400); + for i in 0..1000 { + let domain = format!("domain-{i}.example.com"); + let pkt = make_response(&domain); + cache.insert(&domain, QueryType::A, &pkt); + } + + c.bench_function("cache_lookup_hit_populated", |b| { + b.iter(|| { + cache + .lookup(black_box("domain-500.example.com"), QueryType::A) + .unwrap() + }) + }); +} + +criterion_group!( + benches, + bench_buffer_parse, + bench_buffer_serialize, + bench_packet_clone, + bench_cache_lookup_hit, + bench_cache_lookup_miss, + bench_cache_insert, + bench_round_trip, + bench_cache_populated_lookup, +); +criterion_main!(benches); diff --git a/benches/throughput.rs b/benches/throughput.rs new file mode 100644 index 0000000..e01a25c --- /dev/null +++ b/benches/throughput.rs @@ -0,0 +1,94 @@ +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use std::net::Ipv4Addr; + +use numa::buffer::BytePacketBuffer; +use numa::header::ResultCode; +use numa::packet::DnsPacket; +use numa::question::{DnsQuestion, QueryType}; +use numa::record::DnsRecord; + +fn make_query_wire(domain: &str) -> Vec { + let mut q = DnsPacket::new(); + q.header.id = 0xABCD; + q.header.recursion_desired = true; + q.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + let mut buf = BytePacketBuffer::new(); + q.write(&mut buf).unwrap(); + buf.filled().to_vec() +} + +fn make_response(domain: &str) -> DnsPacket { + let mut pkt = DnsPacket::new(); + pkt.header.id = 0xABCD; + pkt.header.response = true; + pkt.header.recursion_desired = true; + pkt.header.recursion_available = true; + pkt.header.rescode = ResultCode::NOERROR; + pkt.questions + .push(DnsQuestion::new(domain.to_string(), QueryType::A)); + pkt.answers.push(DnsRecord::A { + domain: domain.to_string(), + addr: Ipv4Addr::new(93, 184, 216, 34), + ttl: 300, + }); + pkt +} + +/// Simulates the complete cached query pipeline (sans network I/O): +/// parse → cache lookup → TTL adjust → serialize response +fn simulate_cached_pipeline(query_wire: &[u8], cache: &numa::cache::DnsCache) -> usize { + let mut buf = BytePacketBuffer::from_bytes(query_wire); + let query = DnsPacket::from_buffer(&mut buf).unwrap(); + let q = &query.questions[0]; + + let mut resp = cache.lookup(&q.name, q.qtype).unwrap(); + resp.header.id = query.header.id; + + let mut resp_buf = BytePacketBuffer::new(); + resp.write(&mut resp_buf).unwrap(); + resp_buf.pos() +} + +fn bench_pipeline_throughput(c: &mut Criterion) { + let domains: Vec = (0..100) + .map(|i| format!("domain-{i}.example.com")) + .collect(); + + let mut cache = numa::cache::DnsCache::new(10_000, 60, 86400); + for d in &domains { + cache.insert(d, QueryType::A, &make_response(d)); + } + + let query_wires: Vec> = domains.iter().map(|d| make_query_wire(d)).collect(); + + let mut group = c.benchmark_group("pipeline_throughput"); + + for count in [1, 10, 100] { + group.throughput(Throughput::Elements(count)); + group.bench_with_input(BenchmarkId::from_parameter(count), &count, |b, &count| { + let mut idx = 0usize; + b.iter(|| { + for _ in 0..count { + let wire = &query_wires[idx % query_wires.len()]; + simulate_cached_pipeline(wire, &cache); + idx += 1; + } + }); + }); + } + group.finish(); +} + +/// Measures the overhead of BytePacketBuffer allocation + zero-init +fn bench_buffer_alloc(c: &mut Criterion) { + c.bench_function("buffer_alloc", |b| { + b.iter(|| { + let buf = BytePacketBuffer::new(); + criterion::black_box(buf.pos()); + }) + }); +} + +criterion_group!(benches, bench_pipeline_throughput, bench_buffer_alloc,); +criterion_main!(benches); diff --git a/src/api.rs b/src/api.rs index 2df9d73..1c6283c 100644 --- a/src/api.rs +++ b/src/api.rs @@ -220,7 +220,7 @@ async fn create_overrides( }) .collect::, (StatusCode, String)>>()?; - let mut store = ctx.overrides.lock().unwrap(); + let mut store = ctx.overrides.write().unwrap(); let mut responses = Vec::with_capacity(parsed.len()); for (domain, target, ttl, duration_secs) in parsed { @@ -241,7 +241,7 @@ async fn create_overrides( } async fn list_overrides(State(ctx): State>) -> Json> { - let store = ctx.overrides.lock().unwrap(); + let store = ctx.overrides.read().unwrap(); let entries: Vec = store .list() .into_iter() @@ -254,7 +254,7 @@ async fn get_override( State(ctx): State>, Path(domain): Path, ) -> Result, StatusCode> { - let store = ctx.overrides.lock().unwrap(); + let store = ctx.overrides.read().unwrap(); let entry = store.get(&domain).ok_or(StatusCode::NOT_FOUND)?; Ok(Json(OverrideResponse::from(entry))) } @@ -263,7 +263,7 @@ async fn remove_override( State(ctx): State>, Path(domain): Path, ) -> StatusCode { - let mut store = ctx.overrides.lock().unwrap(); + let mut store = ctx.overrides.write().unwrap(); if store.remove(&domain) { StatusCode::NO_CONTENT } else { @@ -272,7 +272,7 @@ async fn remove_override( } async fn clear_overrides(State(ctx): State>) -> StatusCode { - ctx.overrides.lock().unwrap().clear(); + ctx.overrides.write().unwrap().clear(); StatusCode::NO_CONTENT } @@ -280,7 +280,7 @@ async fn load_environment( State(ctx): State>, Json(req): Json, ) -> Result<(StatusCode, Json), (StatusCode, String)> { - let mut store = ctx.overrides.lock().unwrap(); + let mut store = ctx.overrides.write().unwrap(); for entry in &req.overrides { let duration = entry.duration_secs.or(req.duration_secs); @@ -307,7 +307,7 @@ async fn diagnose( // Check overrides { - let store = ctx.overrides.lock().unwrap(); + let store = ctx.overrides.read().unwrap(); let entry = store.get(&domain_lower); steps.push(DiagnoseStep { source: "override".to_string(), @@ -319,7 +319,7 @@ async fn diagnose( // Check blocklist { - let bl = ctx.blocklist.lock().unwrap(); + let bl = ctx.blocklist.read().unwrap(); let blocked = bl.is_blocked(&domain_lower); steps.push(DiagnoseStep { source: "blocklist".to_string(), @@ -345,7 +345,7 @@ async fn diagnose( // Check cache { - let mut cache = ctx.cache.lock().unwrap(); + let cache = ctx.cache.read().unwrap(); let cached = cache.lookup(&domain_lower, qtype); steps.push(DiagnoseStep { source: "cache".to_string(), @@ -443,11 +443,11 @@ async fn query_log( async fn stats(State(ctx): State>) -> Json { let snap = ctx.stats.lock().unwrap().snapshot(); let (cache_len, cache_max) = { - let cache = ctx.cache.lock().unwrap(); + let cache = ctx.cache.read().unwrap(); (cache.len(), cache.max_entries()) }; - let override_count = ctx.overrides.lock().unwrap().active_count(); - let bl_stats = ctx.blocklist.lock().unwrap().stats(); + let override_count = ctx.overrides.read().unwrap().active_count(); + let bl_stats = ctx.blocklist.read().unwrap().stats(); let upstream = ctx.upstream.lock().unwrap().to_string(); @@ -486,7 +486,7 @@ async fn stats(State(ctx): State>) -> Json { } async fn list_cache(State(ctx): State>) -> Json> { - let cache = ctx.cache.lock().unwrap(); + let cache = ctx.cache.read().unwrap(); let entries: Vec = cache .list() .into_iter() @@ -500,7 +500,7 @@ async fn list_cache(State(ctx): State>) -> Json>) -> StatusCode { - ctx.cache.lock().unwrap().clear(); + ctx.cache.write().unwrap().clear(); StatusCode::NO_CONTENT } @@ -508,7 +508,7 @@ async fn flush_cache_domain( State(ctx): State>, Path(domain): Path, ) -> StatusCode { - ctx.cache.lock().unwrap().remove(&domain); + ctx.cache.write().unwrap().remove(&domain); StatusCode::NO_CONTENT } @@ -519,7 +519,7 @@ async fn health() -> Json { // --- Blocking handlers --- async fn blocking_stats(State(ctx): State>) -> Json { - let stats = ctx.blocklist.lock().unwrap().stats(); + let stats = ctx.blocklist.read().unwrap().stats(); Json(serde_json::json!({ "enabled": stats.enabled, "paused": stats.paused, @@ -539,7 +539,7 @@ async fn blocking_toggle( State(ctx): State>, Json(req): Json, ) -> Json { - ctx.blocklist.lock().unwrap().set_enabled(req.enabled); + ctx.blocklist.write().unwrap().set_enabled(req.enabled); Json(serde_json::json!({ "enabled": req.enabled })) } @@ -557,12 +557,12 @@ async fn blocking_pause( State(ctx): State>, Json(req): Json, ) -> Json { - ctx.blocklist.lock().unwrap().pause(req.minutes * 60); + ctx.blocklist.write().unwrap().pause(req.minutes * 60); Json(serde_json::json!({ "paused_minutes": req.minutes })) } async fn blocking_unpause(State(ctx): State>) -> Json { - ctx.blocklist.lock().unwrap().unpause(); + ctx.blocklist.write().unwrap().unpause(); Json(serde_json::json!({ "paused": false })) } @@ -570,12 +570,12 @@ async fn blocking_check( State(ctx): State>, Path(domain): Path, ) -> Json { - let result = ctx.blocklist.lock().unwrap().check(&domain); + let result = ctx.blocklist.read().unwrap().check(&domain); Json(result) } async fn blocking_allowlist(State(ctx): State>) -> Json> { - let list = ctx.blocklist.lock().unwrap().allowlist(); + let list = ctx.blocklist.read().unwrap().allowlist(); Json(list) } @@ -588,7 +588,7 @@ async fn blocking_allowlist_add( State(ctx): State>, Json(req): Json, ) -> (StatusCode, Json) { - ctx.blocklist.lock().unwrap().add_to_allowlist(&req.domain); + ctx.blocklist.write().unwrap().add_to_allowlist(&req.domain); ( StatusCode::CREATED, Json(serde_json::json!({ "allowed": req.domain })), @@ -599,7 +599,12 @@ async fn blocking_allowlist_remove( State(ctx): State>, Path(domain): Path, ) -> StatusCode { - if ctx.blocklist.lock().unwrap().remove_from_allowlist(&domain) { + if ctx + .blocklist + .write() + .unwrap() + .remove_from_allowlist(&domain) + { StatusCode::NO_CONTENT } else { StatusCode::NOT_FOUND diff --git a/src/cache.rs b/src/cache.rs index 0586bc9..decde82 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -19,7 +19,6 @@ pub struct DnsCache { max_entries: usize, min_ttl: u32, max_ttl: u32, - query_count: u64, } impl DnsCache { @@ -30,29 +29,16 @@ impl DnsCache { max_entries, min_ttl, max_ttl, - query_count: 0, } } - pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option { - self.query_count += 1; - - if self.query_count.is_multiple_of(1000) { - self.evict_expired(); - } - + /// Read-only lookup — expired entries are left in place (cleaned up on insert). + pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option { let type_map = self.entries.get(domain)?; let entry = type_map.get(&qtype)?; let elapsed = entry.inserted_at.elapsed(); if elapsed >= entry.ttl { - // Expired: remove this entry - let type_map = self.entries.get_mut(domain).unwrap(); - type_map.remove(&qtype); - self.entry_count -= 1; - if type_map.is_empty() { - self.entries.remove(domain); - } return None; } diff --git a/src/ctx.rs b/src/ctx.rs index 925ab4a..80b9226 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -1,6 +1,6 @@ use std::net::SocketAddr; use std::path::PathBuf; -use std::sync::Mutex; +use std::sync::{Mutex, RwLock}; use std::time::{Duration, Instant, SystemTime}; use arc_swap::ArcSwap; @@ -27,10 +27,10 @@ use crate::system_dns::ForwardingRule; pub struct ServerCtx { pub socket: UdpSocket, pub zone_map: ZoneMap, - pub cache: Mutex, + pub cache: RwLock, pub stats: Mutex, - pub overrides: Mutex, - pub blocklist: Mutex, + pub overrides: RwLock, + pub blocklist: RwLock, pub query_log: Mutex, pub services: Mutex, pub lan_peers: Mutex, @@ -73,7 +73,7 @@ pub async fn handle_query( // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream // Each lock is scoped to avoid holding MutexGuard across await points. let (response, path) = { - let override_record = ctx.overrides.lock().unwrap().lookup(&qname); + let override_record = ctx.overrides.read().unwrap().lookup(&qname); if let Some(record) = override_record { let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); resp.answers.push(record); @@ -116,7 +116,7 @@ pub async fn handle_query( }), } (resp, QueryPath::Local) - } else if ctx.blocklist.lock().unwrap().is_blocked(&qname) { + } else if ctx.blocklist.read().unwrap().is_blocked(&qname) { let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); match qtype { QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { @@ -136,7 +136,7 @@ pub async fn handle_query( resp.answers = records.clone(); (resp, QueryPath::Local) } else { - let cached = ctx.cache.lock().unwrap().lookup(&qname, qtype); + let cached = ctx.cache.read().unwrap().lookup(&qname, qtype); if let Some(cached) = cached { let mut resp = cached; resp.header.id = query.header.id; @@ -149,7 +149,7 @@ pub async fn handle_query( }; match forward_query(&query, &upstream, ctx.timeout).await { Ok(resp) => { - ctx.cache.lock().unwrap().insert(&qname, qtype, &resp); + ctx.cache.write().unwrap().insert(&qname, qtype, &resp); (resp, QueryPath::Forwarded) } Err(e) => { diff --git a/src/main.rs b/src/main.rs index 8e23e78..7e739aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use std::net::SocketAddr; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, RwLock}; use std::time::Duration; use arc_swap::ArcSwap; @@ -170,14 +170,14 @@ async fn main() -> numa::Result<()> { let ctx = Arc::new(ServerCtx { socket: UdpSocket::bind(&config.server.bind_addr).await?, zone_map: build_zone_map(&config.zones)?, - cache: Mutex::new(DnsCache::new( + cache: RwLock::new(DnsCache::new( config.cache.max_entries, config.cache.min_ttl, config.cache.max_ttl, )), stats: Mutex::new(ServerStats::new()), - overrides: Mutex::new(OverrideStore::new()), - blocklist: Mutex::new(blocklist), + overrides: RwLock::new(OverrideStore::new()), + blocklist: RwLock::new(blocklist), query_log: Mutex::new(QueryLog::new(1000)), services: Mutex::new(service_store), lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), @@ -541,7 +541,7 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) { // Swap under lock — sub-microsecond ctx.blocklist - .lock() + .write() .unwrap() .swap_domains(all_domains, sources); info!( diff --git a/src/override_store.rs b/src/override_store.rs index a1c7bf8..2ae671c 100644 --- a/src/override_store.rs +++ b/src/override_store.rs @@ -64,6 +64,9 @@ impl OverrideStore { ttl: u32, duration_secs: Option, ) -> Result { + // Clean up expired entries on write + self.entries.retain(|_, e| !e.is_expired()); + let domain_lower = domain.to_lowercase(); let (qtype, record) = parse_target(&domain_lower, target, ttl)?; @@ -84,10 +87,10 @@ impl OverrideStore { } /// Hot path: assumes `domain` is already lowercased (the parser does this). - pub fn lookup(&mut self, domain: &str) -> Option { + /// Read-only — expired entries are left in place (cleaned up on write operations). + pub fn lookup(&self, domain: &str) -> Option { let entry = self.entries.get(domain)?; if entry.is_expired() { - self.entries.remove(domain); return None; } Some(entry.record.clone()) diff --git a/src/packet.rs b/src/packet.rs index bca60c2..2c4c85a 100644 --- a/src/packet.rs +++ b/src/packet.rs @@ -46,7 +46,7 @@ impl DnsPacket { result.header.read(buffer)?; for _ in 0..result.header.questions { - let mut question = DnsQuestion::new("".to_string(), QueryType::UNKNOWN(0)); + let mut question = DnsQuestion::new(String::with_capacity(64), QueryType::UNKNOWN(0)); question.read(buffer)?; result.questions.push(question); } @@ -68,34 +68,36 @@ impl DnsPacket { } pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> { - // Filter out UNKNOWN records (e.g. EDNS OPT) that we can't re-serialize - let answers: Vec<_> = self.answers.iter().filter(|r| !r.is_unknown()).collect(); - let authorities: Vec<_> = self - .authorities - .iter() - .filter(|r| !r.is_unknown()) - .collect(); - let resources: Vec<_> = self.resources.iter().filter(|r| !r.is_unknown()).collect(); + // Count known records without allocating filter Vecs + let answer_count = self.answers.iter().filter(|r| !r.is_unknown()).count() as u16; + let auth_count = self.authorities.iter().filter(|r| !r.is_unknown()).count() as u16; + let res_count = self.resources.iter().filter(|r| !r.is_unknown()).count() as u16; let mut header = self.header.clone(); header.questions = self.questions.len() as u16; - header.answers = answers.len() as u16; - header.authoritative_entries = authorities.len() as u16; - header.resource_entries = resources.len() as u16; + header.answers = answer_count; + header.authoritative_entries = auth_count; + header.resource_entries = res_count; header.write(buffer)?; for question in &self.questions { question.write(buffer)?; } - for rec in answers { - rec.write(buffer)?; + for rec in &self.answers { + if !rec.is_unknown() { + rec.write(buffer)?; + } } - for rec in authorities { - rec.write(buffer)?; + for rec in &self.authorities { + if !rec.is_unknown() { + rec.write(buffer)?; + } } - for rec in resources { - rec.write(buffer)?; + for rec in &self.resources { + if !rec.is_unknown() { + rec.write(buffer)?; + } } Ok(()) diff --git a/src/record.rs b/src/record.rs index f525cbb..b7522dc 100644 --- a/src/record.rs +++ b/src/record.rs @@ -70,7 +70,7 @@ impl DnsRecord { } pub fn read(buffer: &mut BytePacketBuffer) -> Result { - let mut domain = String::new(); + let mut domain = String::with_capacity(64); buffer.read_qname(&mut domain)?; let qtype_num = buffer.read_u16()?; @@ -110,7 +110,7 @@ impl DnsRecord { Ok(DnsRecord::AAAA { domain, addr, ttl }) } QueryType::NS => { - let mut ns = String::new(); + let mut ns = String::with_capacity(64); buffer.read_qname(&mut ns)?; Ok(DnsRecord::NS { @@ -120,7 +120,7 @@ impl DnsRecord { }) } QueryType::CNAME => { - let mut cname = String::new(); + let mut cname = String::with_capacity(64); buffer.read_qname(&mut cname)?; Ok(DnsRecord::CNAME { @@ -131,7 +131,7 @@ impl DnsRecord { } QueryType::MX => { let priority = buffer.read_u16()?; - let mut mx = String::new(); + let mut mx = String::with_capacity(64); buffer.read_qname(&mut mx)?; Ok(DnsRecord::MX { -- 2.34.1 From bb7e33619a6bc7136114d15743cfca42012078c5 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 27 Mar 2026 02:01:43 +0200 Subject: [PATCH 5/5] feat: self-host fonts, styled block page, wildcard TLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fonts: - Replace Google Fonts CDN with self-hosted woff2 (73KB, 5 files) - Serve fonts from API server via include_bytes! (dashboard works offline) - Proxy error pages use system fonts (zero external deps when DNS is broken) - Fix Instrument Serif font-weight: use 400 (only available weight) instead of synthetic bold 600/700 Proxy: - Styled "Blocked by Numa" page when blocked domain hits the proxy (was confusing "not a .numa domain" error) - Extract shared error_page() template for 403 + 404 pages (deduplicate ~160 lines of CSS) TLS: - Add wildcard SAN *.numa to cert — unregistered .numa domains get valid HTTPS (styled 404 without cert warning) Co-Authored-By: Claude Opus 4.6 (1M context) --- blog/dns-from-scratch.md | 49 +---- site/blog-template.html | 8 +- site/blog/dns-from-scratch.html | 184 ++++++------------ site/blog/index.html | 8 +- site/dashboard.html | 4 +- site/fonts/dm-sans-italic-latin.woff2 | Bin 0 -> 15172 bytes site/fonts/dm-sans-latin.woff2 | Bin 0 -> 31312 bytes site/fonts/fonts.css | 36 ++++ .../fonts/instrument-serif-italic-latin.woff2 | Bin 0 -> 8444 bytes site/fonts/instrument-serif-latin.woff2 | Bin 0 -> 7828 bytes site/fonts/jetbrains-mono-latin.woff2 | Bin 0 -> 11596 bytes site/index.html | 14 +- src/api.rs | 48 +++++ src/proxy.rs | 155 +++++++++------ src/tls.rs | 9 +- 15 files changed, 265 insertions(+), 250 deletions(-) create mode 100644 site/fonts/dm-sans-italic-latin.woff2 create mode 100644 site/fonts/dm-sans-latin.woff2 create mode 100644 site/fonts/fonts.css create mode 100644 site/fonts/instrument-serif-italic-latin.woff2 create mode 100644 site/fonts/instrument-serif-latin.woff2 create mode 100644 site/fonts/jetbrains-mono-latin.woff2 diff --git a/blog/dns-from-scratch.md b/blog/dns-from-scratch.md index ebd6993..0959fc7 100644 --- a/blog/dns-from-scratch.md +++ b/blog/dns-from-scratch.md @@ -232,25 +232,9 @@ pub fn lookup(&mut self, domain: &str, qtype: QueryType) -> Option { No background thread. No timer. Entries expire lazily. The cache stays consistent because every consumer sees the adjusted TTL. -## Async per-query with tokio +## The resolution pipeline -Each incoming UDP packet spawns a tokio task. The main loop never blocks: - -```rust -loop { - let mut buffer = BytePacketBuffer::new(); - let (_, src_addr) = socket.recv_from(&mut buffer.buf).await?; - - let ctx = Arc::clone(&ctx); - tokio::spawn(async move { - if let Err(e) = handle_query(buffer, src_addr, &ctx).await { - error!("{} | HANDLER ERROR | {}", src_addr, e); - } - }); -} -``` - -Each `handle_query` walks a pipeline. This is the part where "from scratch" pays off — every step is just a function that either returns a response or says "not my problem, pass it on": +Each incoming UDP packet spawns a tokio task. Each task walks a deterministic pipeline — every step either answers or passes to the next: ``` ┌─────────────────────────────────────────────────────┐ @@ -266,12 +250,9 @@ Each `handle_query` walks a pipeline. This is the part where "from scratch" pays │ after N min) proxy+TLS) records) adjusted) (encrypted) │ └──→ Each step either answers or passes to the next. - Adding a feature = inserting a function into this chain. ``` -Want conditional forwarding for Tailscale? Insert a step before the upstream that checks the domain suffix. Want to override `api.example.com` for 5 minutes while debugging? Insert an entry in the overrides step — it auto-expires and the domain goes back to resolving normally. A DNS library would have hidden this pipeline behind an opaque `resolve()` call. - -This is one of those cases where Rust + tokio makes things almost embarrassingly simple. In a synchronous resolver, you'd need a thread pool or hand-rolled event loop. Here, each query is a lightweight future. A slow upstream query doesn't block anything — other queries keep flowing. +This is where "from scratch" pays off. Want conditional forwarding for Tailscale? Insert a step before the upstream. Want to override `api.example.com` for 5 minutes while debugging? Add an entry in the overrides step — it auto-expires. A DNS library would have hidden this pipeline behind an opaque `resolve()` call. ## DNS-over-HTTPS: the "wait, that's it?" moment @@ -316,37 +297,21 @@ If the configured address starts with `https://`, it's DoH. Otherwise, plain UDP ## "Why not just use dnsmasq + nginx + mkcert?" -Fair question — I got this a lot when I first [posted about Numa](https://www.reddit.com/r/programare/). And the answer is: you absolutely can. Those are mature, battle-tested tools. - -The difference is integration. With dnsmasq + nginx + mkcert, you're configuring three tools: DNS resolution, reverse proxy rules, and certificate generation. Each has its own config format, its own lifecycle, its own failure modes. Numa puts the DNS record, the reverse proxy, and the TLS cert behind a single API call: +You absolutely can — those are mature, battle-tested tools. The difference is integration: with dnsmasq + nginx + mkcert, you're configuring three tools with three config formats. Numa puts the DNS record, reverse proxy, and TLS cert behind one API call: ```bash curl -X POST localhost:5380/services -d '{"name":"frontend","target_port":5173}' ``` -That creates the DNS entry, generates a TLS certificate with the correct SAN, and starts proxying — including WebSocket upgrade for Vite HMR. One command, no config files. - -There's also a distinction people miss: **mkcert and certbot solve different problems.** Certbot issues certificates for public domains via Let's Encrypt — it needs DNS validation or an open port 80. Numa generates certificates for `.numa` domains that don't exist publicly. You can't get a Let's Encrypt cert for `frontend.numa`. They're complementary, not alternatives. - -Someone on Reddit told me the real value is "TLS termination + reverse proxy, simple to install, for developers — stop there." Honestly, they might be right about focus. But DNS is the foundation the proxy sits on, and having full control over the resolution pipeline is what makes auto-revert overrides and LAN discovery possible. Sometimes the "unnecessary" part is what makes the interesting part work. - -## The blocklist memory problem - -Numa's ad blocking loads the [Hagezi Pro](https://github.com/hagezi/dns-blocklists) list at startup — ~385,000 domains stored in a `HashSet`. This works, but it consumes ~30MB of memory. For a laptop DNS proxy, that's fine. For embedded devices or a future where you want to run Numa on a router, it's too much. - -The obvious optimization is a **Bloom filter** — a probabilistic data structure that can tell you "definitely not in the set" or "probably in the set" using a fraction of the memory. A Bloom filter for 385K domains with a 0.1% false positive rate would use ~700KB instead of 30MB. The false positives (0.1% of queries hitting domains not in the list) would be blocked unnecessarily, which is acceptable for ad blocking. - -I haven't implemented this yet — the `HashSet` is simple, correct, and 30MB is nothing on a laptop. But if Numa ever needs to run on a router or a Raspberry Pi, this is the first optimization I'd reach for. +That creates the DNS entry, generates a TLS certificate, and starts proxying — including WebSocket upgrade for Vite HMR. One command, no config files. Having full control over the resolution pipeline is what makes auto-revert overrides and LAN discovery possible. ## What I learned **DNS is a 40-year-old protocol that works remarkably well.** The wire format is tight, the caching model is elegant, and the hierarchical delegation system has scaled to billions of queries per day. The things people complain about (DNSSEC complexity, lack of encryption) are extensions bolted on decades later, not flaws in the original design. -**"From scratch" gives you full control.** When I wanted to add ephemeral overrides that auto-revert, it was trivial — just a new step in the resolution pipeline. Conditional forwarding for Tailscale/VPN? Another step. Every feature is a function that takes a query and returns either a response or "pass to the next stage." A DNS library would have hidden this pipeline. +**The hard parts aren't where you'd expect.** Parsing the wire protocol was straightforward (RFC 1035 is well-written). The hard parts were: browsers rejecting wildcard certs under single-label TLDs, macOS resolver quirks (`scutil` vs `/etc/resolv.conf`), and getting multiple processes to bind the same multicast port (`SO_REUSEPORT` on macOS, `SO_REUSEADDR` on Linux). -**The hard parts aren't where you'd expect.** Parsing the wire protocol was straightforward (RFC 1035 is well-written). The hard parts were: browsers rejecting wildcard certs under single-label TLDs (`*.numa` fails — you need per-service SANs), macOS resolver quirks (scutil vs /etc/resolv.conf), and getting multiple processes to bind the same multicast port (`SO_REUSEPORT` on macOS, `SO_REUSEADDR` on Linux). - -**Terminology will get you roasted.** I initially called Numa a "DNS resolver" and got corrected on Reddit — it's a forwarding resolver (DNS proxy). It doesn't walk the delegation chain from root servers; it forwards to an upstream. The distinction matters to people who work with DNS for a living, and being sloppy about it cost me credibility in my first community posts. If you're building in a domain with established terminology, learn the vocabulary before you show up. +**Learn the vocabulary before you show up.** I initially called Numa a "DNS resolver" and got corrected — it's a forwarding resolver. The distinction matters to people who work with DNS professionally, and being sloppy about it cost me credibility in my first community posts. ## What's next diff --git a/site/blog-template.html b/site/blog-template.html index 61bdb3b..0275c1f 100644 --- a/site/blog-template.html +++ b/site/blog-template.html @@ -5,9 +5,7 @@ $title$ — Numa - - - +
-
404
+{body} +
+"## + ) +} + +fn extract_host(req: &Request) -> Option { + req.headers() + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(|h| h.split(':').next().unwrap_or(h).to_lowercase()) +} + +async fn proxy_handler(State(state): State, req: Request) -> axum::response::Response { + let hostname = match extract_host(&req) { + Some(h) => h, + None => { + return (StatusCode::BAD_REQUEST, "missing Host header").into_response(); + } + }; + + let service_name = match hostname.strip_suffix(state.ctx.proxy_tld_suffix.as_str()) { + Some(name) => name.to_string(), + None => { + // Check if this domain was blocked — show a helpful styled page + if state.ctx.blocklist.read().unwrap().is_blocked(&hostname) { + let body = format!( + r#"
🛡
+
Blocked by Numa
+
{0}
+

This domain is on the ad & tracker blocklist.
To allow it, use the dashboard or:

+
$ curl -X POST localhost:5380/blocking/allowlist \
+    -d '{{"domain":"{0}"}}'
"#, + hostname + ); + return ( + StatusCode::FORBIDDEN, + [(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")], + error_page(&format!("Blocked — {}", hostname), &body), + ) + .into_response(); + } + return ( + StatusCode::BAD_GATEWAY, + format!("not a {} domain: {}", state.ctx.proxy_tld_suffix, hostname), + ) + .into_response(); + } + }; + + let request_path = req.uri().path().to_string(); + + let (target_host, target_port, rewritten_path) = { + let store = state.ctx.services.lock().unwrap(); + if let Some(entry) = store.lookup(&service_name) { + let (port, path) = entry.resolve_route(&request_path); + ("localhost".to_string(), port, path) + } else { + let mut peers = state.ctx.lan_peers.lock().unwrap(); + match peers.lookup(&service_name) { + Some((ip, port)) => (ip.to_string(), port, request_path.clone()), + None => { + let body = format!( + r#"
404
{0}{1}

This service isn't registered yet.
Add it from the dashboard or:

$ curl -X POST numa.numa:5380/services \
     -H 'Content-Type: application/json' \
     -d '{{"name":"{0}","target_port":3000}}'
-
ma-ia hii, ma-ia huu, ma-ia haa, ma-ia ha-ha
- -"##, +
ma-ia hii, ma-ia huu, ma-ia haa, ma-ia ha-ha
"#, service_name, state.ctx.proxy_tld_suffix - ), - ) - .into_response() + ); + return ( + StatusCode::NOT_FOUND, + [(hyper::header::CONTENT_TYPE, "text/html; charset=utf-8")], + error_page( + &format!("404 — {}{}", service_name, state.ctx.proxy_tld_suffix), + &body, + ), + ) + .into_response(); } } } diff --git a/src/tls.rs b/src/tls.rs index 966b1f1..a4d91bf 100644 --- a/src/tls.rs +++ b/src/tls.rs @@ -112,8 +112,15 @@ fn generate_service_cert( .distinguished_name .push(DnType::CommonName, format!("Numa .{} services", tld)); - // Add each service as an explicit SAN: numa.numa, peekm.numa, api.numa, etc. + // Add a wildcard SAN so any .numa domain gets a valid cert (including + // unregistered services — lets the proxy show a styled 404 over HTTPS). + // Also add each service explicitly for clients that don't match wildcards. let mut sans = Vec::new(); + let wildcard = format!("*.{}", tld); + match wildcard.clone().try_into() { + Ok(ia5) => sans.push(SanType::DnsName(ia5)), + Err(e) => warn!("invalid wildcard SAN {}: {}", wildcard, e), + } for name in service_names { let fqdn = format!("{}.{}", name, tld); match fqdn.clone().try_into() { -- 2.34.1