61 Commits

Author SHA1 Message Date
Razvan Dimescu
120ba5200e chore: bump version to 0.13.1 2026-04-14 13:31:35 +03:00
Razvan Dimescu
45046bcf6e Merge pull request #101 from razvandimescu/fix/forward-tls-upstream
fix: accept tls:// and https:// in [[forwarding]] upstreams
2026-04-14 13:09:58 +03:00
Razvan Dimescu
b4b939c78b fix: accept tls:// and https:// in [[forwarding]] upstreams
Config-level forwarding rules were parsed with the UDP-only
`parse_upstream_addr` helper, silently rejecting the DoT/DoH schemes
that the rest of the forwarding pipeline already supports.

Widen `ForwardingRule.upstream` from `SocketAddr` to `Upstream` so
config rules reuse the same parser as `[upstream].address` and
`fallback`. Demote `parse_upstream_addr` to `pub(crate)` to prevent
the same mistake recurring.

Closes #100.
2026-04-14 09:22:24 +03:00
Razvan Dimescu
9a85e271ec Merge pull request #99 from razvandimescu/fix/aur-llvm-libs
fix: add llvm-libs to AUR makedepends
2026-04-13 17:09:08 +03:00
Razvan Dimescu
7dc1a0686f fix: add llvm-libs to AUR makedepends
Fixes #97 — on minimal Arch installs, rustc fails with
"error while loading shared libraries: libLLVM.so" because
llvm-libs isn't pulled in transitively.
2026-04-13 15:58:52 +03:00
Razvan Dimescu
a02722cdf9 Merge pull request #98 from razvandimescu/docker-support
feat: Docker support with multi-arch GHCR images
2026-04-13 15:53:56 +03:00
Razvan Dimescu
3b77dcff61 feat: Docker support — multi-arch GHCR images on release
Add CI workflow to build linux/amd64 + linux/arm64 images and push to
ghcr.io/razvandimescu/numa on tag. Fix Dockerfile (missing benches/),
bake container-aware config (API + proxy bind 0.0.0.0), add Docker
section to README.
2026-04-13 15:48:29 +03:00
Razvan Dimescu
7cc110a0a1 ci: skip CI and AUR builds for blog/site-only changes
Add paths-ignore for site/, blog/, drafts/, *.md, and blog scripts
so content-only pushes don't trigger cargo builds or AUR publishes.
2026-04-13 15:02:19 +03:00
Razvan Dimescu
75fe625f39 blog: drop redundant Numa intro from opening paragraph 2026-04-13 14:48:34 +03:00
Razvan Dimescu
908d076d9b blog: pain-first opening, I-voice, forward-looking close
- Open with shared reqwest pain, not the tool name
- Switch "we" to "I" for personal voice (playbook: solo dev > corporate)
- Replace Unbound feature-gap excuses with what I'm exploring next
  (persistent SRTT, aggressive NSEC, adaptive hedge delays)
- Add context line linking hero cards to the recursive section
2026-04-13 14:37:24 +03:00
Razvan Dimescu
5381e65be4 Merge pull request #96 from razvandimescu/blog/fixing-doh-tail-latency
blog: fixing DoH tail latency post
2026-04-13 14:08:40 +03:00
Razvan Dimescu
6b0a30d004 blog: add fixing DoH tail latency post + blog infrastructure
New post on reqwest HTTP/2 window tuning and request hedging
(Dean & Barroso's "The Tail at Scale" applied to DNS forwarding).
Covers DoH forwarding p99 improvement and cold recursive
resolution from 2.3s to 538ms.

Also adds blog build infrastructure: index generation script,
draft preview server, hero metrics/before-after CSS, and
normalizes date format across existing posts.
2026-04-13 13:49:40 +03:00
Razvan Dimescu
169679bfe4 Merge pull request #95 from razvandimescu/fix/forwarding-precedes-special-use
fix: forwarding rules override special-use NXDOMAIN
2026-04-13 09:37:19 +03:00
Razvan Dimescu
d3f046da4c style: assert loopback addr in subdomain test, trim verbose comment 2026-04-13 08:10:26 +03:00
Razvan Dimescu
0bdde40f40 test: verify forwarded response content from mock upstream 2026-04-13 08:07:58 +03:00
Razvan Dimescu
155c1c4da0 test: full-pipeline coverage for every resolve_query step
Test each pipeline stage in isolation through resolve_query:
- override takes precedence over all other paths
- localhost and *.localhost resolve to loopback
- local zone returns configured records
- .tld proxy resolves registered services to loopback
- blocklist sinkholes to 0.0.0.0
- cache hit returns stored response without upstream
2026-04-13 08:04:59 +03:00
Razvan Dimescu
b40004fe5e refactor: extract shared test infrastructure into testutil module
- test_ctx(): single ServerCtx builder, replaces 3 copies (ctx/api/dot)
- mock_upstream(): canned DNS response server for forwarding tests
- blackhole_upstream(): unresponsive socket for timeout tests
- Removes ~100 lines of duplicated 30-field struct literals
2026-04-13 07:56:47 +03:00
Razvan Dimescu
b8ddc16027 refactor: return QueryPath from resolve_query, add mock upstream to tests
resolve_query now returns (BytePacketBuffer, QueryPath) so callers
and tests can inspect the resolution path without reading the query
log. Production call sites (UDP, DoT, DoH) destructure and ignore it.

The forwarding test now uses a mock UDP upstream that replies with a
canned response, asserting QueryPath::Forwarded instead of != Local.
2026-04-13 07:51:14 +03:00
Razvan Dimescu
48f67be2f1 refactor: deduplicate test_ctx by delegating to test_ctx_with_forwarding 2026-04-13 07:39:55 +03:00
Razvan Dimescu
ca00846393 fix: forwarding rules override special-use NXDOMAIN for private PTR zones
Explicit [[forwarding]] rules now take precedence over the RFC 6303
special-use domain intercept. Previously, PTR queries for private
ranges (e.g. 168.192.in-addr.arpa) always returned local NXDOMAIN
even when a forwarding rule pointed them at a corporate DNS server.

Add full-pipeline resolve_query test harness (test_ctx + resolve_in_test)
and two tests covering both the default behavior and the override.

Closes #94
2026-04-13 07:36:53 +03:00
Razvan Dimescu
4d4e48bbd6 chore: bump version to 0.13.0 2026-04-13 01:05:20 +03:00
Razvan Dimescu
724c4a6017 Merge pull request #91 from razvandimescu/docs/readme-update
docs: update README with v0.13.0 features
2026-04-13 01:03:16 +03:00
Razvan Dimescu
2b29a44ee0 docs: remove unfair NextDNS comparison from performance section
Comparing local cache (0.8ms) vs a remote service (37ms) measures
network latency, not resolver quality. Any local resolver would
show the same advantage. Replaced with AdGuard Home comparison
which is a fair local-to-local benchmark.
2026-04-13 01:02:10 +03:00
Razvan Dimescu
588e5226fd Merge pull request #92 from razvandimescu/bench/vs-adguard
bench: add --vs-adguard comparison mode
2026-04-13 01:00:20 +03:00
Razvan Dimescu
501902d569 bench: add --vs-adguard mode for Numa vs AdGuard Home comparison
AdGuard Home on port 5457, both forwarding via DoH. Cached queries
tied at 0.1ms. On degraded networks hedging hurts p99 (28ms vs 10ms
without) — both requests pay the same high RTT with no random spikes
to rescue. On clean networks hedging wins.
2026-04-13 00:56:58 +03:00
Razvan Dimescu
77d2c8bbcd docs: update README comparison table, performance, and roadmap
- Comparison table: add DoH/DoT upstream, DoH server, request hedging,
  serve-stale + prefetch, conditional forwarding rows
- Performance: update with current benchmark numbers (0.1ms cached,
  47x NextDNS, p99 -28% vs Unbound)
- Roadmap: add hedging, serve-stale, conditional forwarding, DoT upstream
- Fix broken benchmarks link (bench/ → benches/)
2026-04-13 00:18:52 +03:00
Razvan Dimescu
274338e7f9 Merge pull request #88 from razvandimescu/fix/doh-loopback-san
fix: DoH endpoint accepts loopback, TLS cert includes IP SANs
2026-04-13 00:03:30 +03:00
Razvan Dimescu
305935ed98 style: rustfmt strip_port 2026-04-12 23:59:51 +03:00
Razvan Dimescu
bd505813b6 test: verify TLS cert SANs (wildcard, services, loopback, localhost, bare TLD)
Parse the generated DER cert with x509-parser to assert the exact SAN
set, catching silent try_into() failures that a params-level test
would miss.
2026-04-12 23:54:55 +03:00
Razvan Dimescu
115a55b199 fix: bracketed IPv6, localhost SAN, split host-check helpers
- is_doh_host split into strip_port + is_loopback_host + is_tld_match
- strip_port handles bracketed IPv6 ([::1]:443) and rejects bare IPv6
- Add [::1] to accepted loopback hosts, add localhost DNS SAN to cert
- Remove dead sans.is_empty() guard (loopback IPs always present)
2026-04-12 23:54:26 +03:00
Razvan Dimescu
3665deb56b fix: accept loopback addresses for DoH and add IP SANs to TLS cert
The DoH endpoint rejected requests with Host: 127.0.0.1/::1/localhost,
and the generated TLS cert had no IP SANs — so browsers couldn't use
https://127.0.0.1/dns-query even with the CA trusted.

- is_doh_host now accepts 127.0.0.1, ::1, localhost (with optional port)
- TLS cert includes 127.0.0.1 and ::1 IP SANs, plus bare TLD DNS SAN

Closes #87
2026-04-12 23:54:26 +03:00
Razvan Dimescu
c074d728e9 Merge pull request #90 from razvandimescu/feat/wire-forwarding-hedging
feat: transport protocol tracking with dashboard visualization
2026-04-12 23:38:57 +03:00
Razvan Dimescu
2101dfcf17 feat: transport protocol tracking (UDP/TCP/DoT/DoH) with dashboard visualization
Thread Transport enum through resolve pipeline, record per-query
transport in stats and query log. Dashboard gets bar chart panel
with encryption %, transport column in query log, and filter dropdown.
2026-04-12 22:14:26 +03:00
Razvan Dimescu
27dc53aebb Merge pull request #85 from razvandimescu/feat/wire-forwarding-hedging
feat: wire-level forwarding, cache, and request hedging
2026-04-12 22:02:45 +03:00
Razvan Dimescu
8085c10687 docs: document hedge_ms, tls:// upstream, update max_entries default in numa.toml 2026-04-12 21:37:59 +03:00
Razvan Dimescu
02e1449a45 feat: enable request hedging for all upstream protocols
Hedging was DoH-only (hyper dispatch spike mitigation). Now applies to
UDP (rescues packet loss) and DoT (rescues TLS handshake stalls) too.
Same-upstream hedging: fires a second independent request after hedge_ms
delay. First response wins. Disable with hedge_ms = 0.
2026-04-12 21:34:47 +03:00
Razvan Dimescu
50828c411a fix: cold benchmark uses 1 round per domain for genuine cold measurements
With ROUNDS=10, only the first query per domain was truly cold — the
other 9 hit cached NS delegations at <1ms, diluting the median to
0.4ms. Now cold mode uses 1 round so every sample is a real cold
resolve. Also extracted compare_two_rounds to support per-mode rounds.
2026-04-12 21:00:24 +03:00
Razvan Dimescu
5184891985 fix: cold benchmark cache-busting with PID prefix and flush
Re-runs of --vs-unbound-cold were hitting stale cache entries from
prior runs. The static COUNTER reset to 0 each process, generating
the same c0.example.com subdomains. With the 1-hour stale window,
entries from 10 minutes ago served as stale hits.

Fix: prefix with PID (r{pid}-c{n}.domain) and flush Numa's cache
before cold benchmarks.
2026-04-12 20:50:04 +03:00
Razvan Dimescu
6d9ee14ea6 refactor: unify warm_stale/warm_domain, remove raw_wire alloc, add Freshness enum
- Extract refresh_entry in ctx.rs — warm_domain in main.rs now delegates
  to it instead of duplicating the resolve+cache logic (~40 lines removed)
- Eliminate unconditional .to_vec() of raw wire on every UDP/DoT query —
  pass &buffer.buf[..len] directly (zero-cost for cache hits)
- Replace bare bool stale flag with Freshness enum (Fresh/NearExpiry/Stale)
  making the three states self-documenting at every call site
2026-04-12 19:56:42 +03:00
Razvan Dimescu
3c49b0e65d fix: deduplicate background refresh with per-domain guard
Multiple stale queries for the same domain now spawn only one background
refresh. A HashSet<(String, QueryType)> on ServerCtx tracks in-flight
refreshes; subsequent stale hits for the same key skip the spawn.
2026-04-12 19:49:23 +03:00
Razvan Dimescu
8ef95383a2 feat: prefetch at <10% TTL remaining, add stale behavior tests
Entries with <10% TTL remaining are now marked stale on lookup,
triggering a background refresh before they expire. Combined with
the serve-stale + background refresh from the previous commit, this
means entries are proactively refreshed — matching Unbound's prefetch
behavior.
2026-04-12 19:46:14 +03:00
Razvan Dimescu
571ce2f013 feat: background refresh on stale cache hit (RFC 8767 revalidation)
When a cached entry is expired but within the 1-hour stale window,
serve it immediately with TTL=1 AND spawn a background re-resolve.
The next query gets a fresh entry instead of another stale serve.

Without this, stale entries were served repeatedly for up to an hour
with no refresh — effectively ignoring TTL.
2026-04-12 19:42:56 +03:00
Razvan Dimescu
043a7e1ba5 feat: raise cache default to 100K entries, evict stalest instead of dropping
The 10K cap was too conservative — the blocklist alone holds 400K domains.
At ~100 bytes per wire entry, 100K entries is ~10MB.

When the cache is full and evict_expired doesn't free enough slots,
evict_stalest removes the entry with the least remaining TTL instead of
silently discarding the new insert.
2026-04-12 19:23:28 +03:00
Razvan Dimescu
05d5a5145f refactor: remove unused extract_question and read_wire_qname from wire.rs 2026-04-12 18:46:03 +03:00
Razvan Dimescu
15058aea83 bench: add --vs-nextdns, --vs-unbound-cold modes with mode validation
- --vs-nextdns: Numa local cache vs NextDNS cloud (45.90.28.0)
- --vs-unbound-cold: unique random subdomains, no record cache hits
- check_numa_mode validates forward/recursive mode before running
- numa-bench-recursive.toml config for cold benchmarks
2026-04-12 18:41:09 +03:00
Razvan Dimescu
628ed00074 refactor: extract cache_and_parse, remove dead truncation log, restore TCP_TIMEOUT to 400ms 2026-04-12 18:40:46 +03:00
Razvan Dimescu
85cff052a4 fix: restore TCP_TIMEOUT to 400ms (test race was the real issue) 2026-04-12 18:40:46 +03:00
Razvan Dimescu
67b472fea7 fix: serialize tests that share global UDP_DISABLED state
The tcp_only_iterative_resolution, tcp_fallback_resolves_when_udp_blocked,
tcp_fallback_handles_nxdomain, and udp_auto_disable_resets tests all mutate
global UDP_DISABLED / UDP_FAILURES atomics. Under cargo test parallelism,
udp_auto_disable_resets would reset the flag mid-flight causing other tests
to attempt UDP against TCP-only mock servers and time out.

Fix: static Mutex serializes tests that depend on global UDP state.
Also: tcp_only_iterative_resolution now calls forward_tcp directly,
removing its dependence on the flag entirely.
2026-04-12 18:40:46 +03:00
Razvan Dimescu
700cca9cb6 style: rustfmt warm_domain 2026-04-12 18:40:46 +03:00
Razvan Dimescu
f705f8c49f fix: bump TCP_TIMEOUT to 800ms to fix flaky CI test 2026-04-12 18:40:46 +03:00
Razvan Dimescu
17a1a6ddba refactor: remove forward_with_failover duplication, fix warm-branch hedge bug
- Remove forward_with_failover (parsed): warm_domain now uses _raw + insert_wire
- forward_udp delegates to forward_udp_raw (single UDP socket implementation)
- forward_query uses unified _raw path for all protocols
- Fix send_query_hedged warm branch: bare select! dropped secondary on primary
  error instead of waiting for it — now drains both futures like the cold branch
- Remove pointless raw_len = len rename
2026-04-12 18:40:46 +03:00
Razvan Dimescu
72b540a44a feat: wire-level cache, serve-stale, raw wire passthrough
- Cache stores raw DNS wire bytes + TTL offsets (2.4x memory reduction)
- Serve-stale (RFC 8767): expired entries returned with TTL=1 for 1hr
- handle_query captures raw_len from recv_from for zero-copy forwarding
- resolve_query accepts raw wire bytes, forwards without re-serializing
- wire.rs: TTL offset scanner, ID/TTL patching, question extraction
- 52 wire tests + 16 cache regression tests
2026-04-12 18:40:46 +03:00
Razvan Dimescu
c1b651aa63 chore: remove obsolete bash benchmark script 2026-04-12 18:40:46 +03:00
Razvan Dimescu
5d9a3a809b feat: DoT client, recursive optimization, bench refactor
- Add DoT forwarding client (tls://IP#hostname upstream config)
- Recursive: cache NS delegations, serve-stale (RFC 8767), parallel
  NS queries on cold, no TCP fallback on individual UDP timeouts,
  400ms NS/TCP timeout (down from 800/1500ms)
- Reduce recursive p99 from 2367ms to 402ms (vs Unbound's 148ms)
- Refactor benchmark suite: generic compare_two engine, delete
  one-off diagnostics (1969 → 750 lines)
- Code cleanup: forward_query delegates to _raw, Option<String>
  for tls_name, saturating_sub for ns_idx
2026-04-12 18:40:46 +03:00
Razvan Dimescu
7efac85836 feat: wire-level forwarding, cache, request hedging, and DoH keepalive
Wire-level forwarding path skips DnsPacket parse/serialize on the hot
path. Cache stores raw wire bytes with pre-scanned TTL offsets — patches
ID + TTLs in-place on lookup instead of cloning parsed packets.

Request hedging (Dean & Barroso "Tail at Scale") fires a second
parallel request after a configurable delay (default 10ms) when
the primary upstream stalls. DoH keepalive loop prevents idle
HTTP/2 + TLS connection teardown.

Recursive resolver now hedges across multiple NS addresses and
caches NS delegation records to skip TLD re-queries.

Integration test harness polls /blocking/stats instead of fixed
sleep, eliminating the blocklist-download race condition.
2026-04-12 18:39:48 +03:00
Razvan Dimescu
4f46550283 Merge pull request #89 from razvandimescu/feat/dot-client
feat: DoT (DNS over TLS) client upstream
2026-04-12 18:39:17 +03:00
Razvan Dimescu
05baad0cc0 feat: DoT (DNS over TLS) client upstream
Adds tls:// upstream support for forwarding queries over DNS-over-TLS
(RFC 7858). Parses tls://IP:PORT#hostname format, with default port 853.

- New Upstream::Dot variant with TLS connector
- forward_dot: length-prefixed DNS over TLS stream
- build_dot_connector: system root CAs via webpki-roots
- parse_upstream handles tls:// prefix

Example config:
  address = ["tls://9.9.9.9#dns.quad9.net"]
2026-04-12 18:35:06 +03:00
Razvan Dimescu
7047767dc2 feat: per-suffix conditional forwarding rules (#82) (#84)
* feat: per-suffix conditional forwarding rules in numa.toml (#82)

Adds a `[[forwarding]]` config section so users can explicitly route
domain suffixes to specific upstreams. Config-declared rules take
precedence over auto-discovered rules (macOS scutil, Linux search
domains) via first-match semantics.

Example — the reporter's reverse-DNS case:

  [[forwarding]]
  suffix = "168.192.in-addr.arpa"
  upstream = "100.90.1.63:5361"

Bare IPs default to port 53. IPv6 is supported via
parse_upstream_addr. ForwardingRule::new() constructor replaces
direct struct-literal construction, and make_rule() now delegates
to parse_upstream_addr to fix a latent IPv6 parsing bug.

* feat: accept suffix as string or array in [[forwarding]] rules

Reuses existing string_or_vec deserializer so users can write:
  suffix = ["168.192.in-addr.arpa", "onsite"]
instead of repeating [[forwarding]] blocks per suffix.

* style: rustfmt

* refactor: drop config_count from merge_forwarding_rules return

Log config rules directly from config.forwarding before merging,
keeping the merge API clean of logging concerns.
2026-04-12 06:12:08 +03:00
Razvan Dimescu
22bebb85a0 fix: config path advisory ignores XDG file on interactive root (#81) (#83)
Port-53 and TLS-data-dir advisories told users to create
~/.config/numa/numa.toml, but config_dir() routed root to
/var/lib/numa/ and load_config never consulted the XDG path, so
the file the user created was silently ignored.

New suggested_config_path() helper prefers $HOME/.config/numa/
when HOME is set (and isn't "/" or empty), with config_dir() as
lazy fallback. Used by both advisories and by load_config as an
additional candidate, so the advised path is the path numa
actually reads. Runtime state (services.json, TLS CA) stays in
FHS — config_dir()/data_dir() are intentionally unchanged to
keep continuity with the installed daemon.

End-to-end replication + regression check in
tests/docker/issue-81.sh: four scenarios (replication and
existing-install, each against main and fix), all matching
expectations.
2026-04-12 02:17:33 +03:00
Razvan Dimescu
289f2b973b chore: remove built blog HTML from tracking (built by CI)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:10:13 +03:00
Razvan Dimescu
fb4cbe0b2a chore: update DoT blog post — mark DoH server as shipped in v0.12.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:08:09 +03:00
43 changed files with 5667 additions and 441 deletions

View File

@@ -3,8 +3,22 @@ name: CI
on: on:
push: push:
branches: [main] branches: [main]
paths-ignore:
- 'site/**'
- 'blog/**'
- 'drafts/**'
- '*.md'
- 'scripts/serve-site.sh'
- 'scripts/generate-blog-index.sh'
pull_request: pull_request:
branches: [main] branches: [main]
paths-ignore:
- 'site/**'
- 'blog/**'
- 'drafts/**'
- '*.md'
- 'scripts/serve-site.sh'
- 'scripts/generate-blog-index.sh'
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always

45
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,45 @@
name: Docker
on:
push:
tags:
- 'v*'
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -23,6 +23,13 @@ name: Publish - Arch Linux AUR Package
on: on:
push: push:
branches: [main] branches: [main]
paths-ignore:
- 'site/**'
- 'blog/**'
- 'drafts/**'
- '*.md'
- 'scripts/serve-site.sh'
- 'scripts/generate-blog-index.sh'
workflow_dispatch: workflow_dispatch:
permissions: permissions:

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ CLAUDE.md
docs/ docs/
site/blog/posts/ site/blog/posts/
ios/ ios/
drafts/
site/blog/index.html

462
Cargo.lock generated
View File

@@ -82,6 +82,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "anyhow"
version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]] [[package]]
name = "arc-swap" name = "arc-swap"
version = "1.9.0" version = "1.9.0"
@@ -142,6 +148,17 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -410,6 +427,21 @@ dependencies = [
"itertools", "itertools",
] ]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-deque" name = "crossbeam-deque"
version = "0.8.6" version = "0.8.6"
@@ -493,6 +525,18 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "enum-as-inner"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "env_filter" name = "env_filter"
version = "1.0.1" version = "1.0.1"
@@ -554,6 +598,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -679,11 +729,24 @@ dependencies = [
"cfg-if", "cfg-if",
"js-sys", "js-sys",
"libc", "libc",
"r-efi", "r-efi 5.3.0",
"wasip2", "wasip2",
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "getrandom"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi 6.0.0",
"wasip2",
"wasip3",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.4.13" version = "0.4.13"
@@ -714,12 +777,82 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.16.1" version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hickory-proto"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
dependencies = [
"async-trait",
"bytes",
"cfg-if",
"data-encoding",
"enum-as-inner",
"futures-channel",
"futures-io",
"futures-util",
"h2",
"http",
"idna",
"ipnet",
"once_cell",
"rand",
"ring",
"rustls",
"thiserror",
"tinyvec",
"tokio",
"tokio-rustls",
"tracing",
"url",
"webpki-roots 0.26.11",
]
[[package]]
name = "hickory-resolver"
version = "0.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
dependencies = [
"cfg-if",
"futures-util",
"hickory-proto",
"ipconfig",
"moka",
"once_cell",
"parking_lot",
"rand",
"resolv-conf",
"rustls",
"smallvec",
"thiserror",
"tokio",
"tokio-rustls",
"tracing",
"webpki-roots 0.26.11",
]
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -802,7 +935,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
"tower-service", "tower-service",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]] [[package]]
@@ -909,6 +1042,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -937,7 +1076,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"widestring",
"windows-registry",
"windows-result",
"windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -1029,6 +1183,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.183" version = "0.2.183"
@@ -1041,6 +1201,15 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.29"
@@ -1098,6 +1267,23 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moka"
version = "0.12.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
dependencies = [
"crossbeam-channel",
"crossbeam-epoch",
"crossbeam-utils",
"equivalent",
"parking_lot",
"portable-atomic",
"smallvec",
"tagptr",
"uuid",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1144,13 +1330,15 @@ dependencies = [
[[package]] [[package]]
name = "numa" name = "numa"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"axum", "axum",
"criterion", "criterion",
"env_logger", "env_logger",
"futures", "futures",
"hickory-proto",
"hickory-resolver",
"http", "http",
"http-body-util", "http-body-util",
"hyper", "hyper",
@@ -1170,6 +1358,8 @@ dependencies = [
"tokio-rustls", "tokio-rustls",
"toml", "toml",
"tower", "tower",
"webpki-roots 1.0.6",
"x509-parser",
] ]
[[package]] [[package]]
@@ -1186,6 +1376,10 @@ name = "once_cell"
version = "1.21.4" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
@@ -1209,6 +1403,29 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -1304,6 +1521,16 @@ dependencies = [
"zerocopy", "zerocopy",
] ]
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -1389,6 +1616,12 @@ version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]] [[package]]
name = "rand" name = "rand"
version = "0.9.2" version = "0.9.2"
@@ -1452,6 +1685,15 @@ dependencies = [
"yasna", "yasna",
] ]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.12.3" version = "1.12.3"
@@ -1517,9 +1759,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"webpki-roots", "webpki-roots 1.0.6",
] ]
[[package]]
name = "resolv-conf"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1617,6 +1865,18 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -1779,6 +2039,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "tagptr"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
@@ -2037,6 +2303,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.9.0" version = "0.9.0"
@@ -2067,6 +2339,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9"
dependencies = [
"getrandom 0.4.2",
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.5.0" version = "2.5.0"
@@ -2101,6 +2384,15 @@ dependencies = [
"wit-bindgen", "wit-bindgen",
] ]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen",
]
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.115" version = "0.2.115"
@@ -2156,6 +2448,40 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"hashbrown 0.15.5",
"indexmap",
"semver",
]
[[package]] [[package]]
name = "web-sys" name = "web-sys"
version = "0.3.92" version = "0.3.92"
@@ -2176,6 +2502,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.6",
]
[[package]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "1.0.6" version = "1.0.6"
@@ -2185,6 +2520,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]] [[package]]
name = "winapi" name = "winapi"
version = "0.3.9" version = "0.3.9"
@@ -2222,6 +2563,35 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -2389,6 +2759,88 @@ name = "wit-bindgen"
version = "0.51.0" version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]] [[package]]
name = "writeable" name = "writeable"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "numa" name = "numa"
version = "0.12.0" version = "0.13.1"
authors = ["razvandimescu <razvan@dimescu.com>"] authors = ["razvandimescu <razvan@dimescu.com>"]
edition = "2021" edition = "2021"
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS" description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
@@ -31,11 +31,15 @@ arc-swap = "1"
ring = "0.17" ring = "0.17"
rustls-pemfile = "2.2.0" rustls-pemfile = "2.2.0"
qrcode = { version = "0.14", default-features = false, features = ["svg"] } qrcode = { version = "0.14", default-features = false, features = ["svg"] }
webpki-roots = "1"
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.8", features = ["html_reports"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
http = "1" http = "1"
hickory-resolver = { version = "0.25", features = ["https-ring", "webpki-roots"] }
hickory-proto = "0.25"
x509-parser = "0.18"
[[bench]] [[bench]]
name = "hot_path" name = "hot_path"
@@ -48,3 +52,7 @@ harness = false
[[bench]] [[bench]]
name = "dnssec" name = "dnssec"
harness = false harness = false
[[bench]]
name = "recursive_compare"
harness = false

View File

@@ -6,6 +6,7 @@ RUN mkdir src && echo 'fn main() {}' > src/main.rs && echo '' > src/lib.rs
RUN cargo build --release 2>/dev/null || true RUN cargo build --release 2>/dev/null || true
RUN rm -rf src RUN rm -rf src
COPY src/ src/ COPY src/ src/
COPY benches/ benches/
COPY site/ site/ COPY site/ site/
COPY numa.toml com.numa.dns.plist numa.service ./ COPY numa.toml com.numa.dns.plist numa.service ./
RUN touch src/main.rs src/lib.rs RUN touch src/main.rs src/lib.rs
@@ -13,5 +14,6 @@ RUN cargo build --release
FROM alpine:3.23 FROM alpine:3.23
COPY --from=builder /app/target/release/numa /usr/local/bin/numa COPY --from=builder /app/target/release/numa /usr/local/bin/numa
RUN mkdir -p /root/.config/numa && printf '[server]\napi_bind_addr = "0.0.0.0"\n\n[proxy]\nenabled = true\nbind_addr = "0.0.0.0"\n' > /root/.config/numa/numa.toml
EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp
ENTRYPOINT ["numa"] ENTRYPOINT ["numa"]

View File

@@ -32,6 +32,19 @@ blog:
pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \ pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \
echo " $$f → site/blog/posts/$$name.html"; \ echo " $$f → site/blog/posts/$$name.html"; \
done done
@scripts/generate-blog-index.sh
blog-drafts: blog
@if [ -d drafts ] && ls drafts/*.md >/dev/null 2>&1; then \
for f in drafts/*.md; do \
name=$$(basename "$$f" .md); \
pandoc "$$f" --template=site/blog-template.html -o "site/blog/posts/$$name.html"; \
echo " $$f → site/blog/posts/$$name.html (draft)"; \
done; \
BLOG_INCLUDE_DRAFTS=1 scripts/generate-blog-index.sh; \
else \
echo " No drafts found"; \
fi
release: release:
ifndef VERSION ifndef VERSION

View File

@@ -9,7 +9,7 @@ url="https://github.com/razvandimescu/numa"
license=('MIT') license=('MIT')
options=('!lto') options=('!lto')
depends=('gcc-libs' 'glibc') depends=('gcc-libs' 'glibc')
makedepends=('cargo' 'git') makedepends=('cargo' 'git' 'llvm-libs')
provides=("$_pkgname") provides=("$_pkgname")
conflicts=("$_pkgname") conflicts=("$_pkgname")
backup=('etc/numa.toml') backup=('etc/numa.toml')

View File

@@ -27,6 +27,9 @@ yay -S numa-git
# Windows — download from GitHub Releases # Windows — download from GitHub Releases
# All platforms # All platforms
cargo install numa cargo install numa
# Docker
docker run -d --name numa --network host ghcr.io/razvandimescu/numa
``` ```
```bash ```bash
@@ -102,6 +105,26 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
**Hub mode**: run one instance with `bind_addr = "0.0.0.0:53"` and point other devices' DNS to it — they get ad blocking + `.numa` resolution without installing anything. **Hub mode**: run one instance with `bind_addr = "0.0.0.0:53"` and point other devices' DNS to it — they get ad blocking + `.numa` resolution without installing anything.
## Docker
```bash
# Recommended — host networking (Linux)
docker run -d --name numa --network host ghcr.io/razvandimescu/numa
# Port mapping (macOS/Windows Docker Desktop)
docker run -d --name numa -p 53:53/udp -p 53:53/tcp -p 5380:5380 ghcr.io/razvandimescu/numa
```
Dashboard at `http://localhost:5380`. The image binds the API and proxy to `0.0.0.0` by default. Override with a custom config:
```bash
docker run -d --name numa --network host \
-v /path/to/numa.toml:/root/.config/numa/numa.toml \
ghcr.io/razvandimescu/numa
```
Multi-arch: `linux/amd64` and `linux/arm64`.
## How It Compares ## How It Compares
| | Pi-hole | AdGuard Home | Unbound | Numa | | | Pi-hole | AdGuard Home | Unbound | Numa |
@@ -113,14 +136,18 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
| DNSSEC validation | — | — | Yes | Yes (RSA, ECDSA, Ed25519) | | DNSSEC validation | — | — | Yes | Yes (RSA, ECDSA, Ed25519) |
| Ad blocking | Yes | Yes | — | 385K+ domains | | Ad blocking | Yes | Yes | — | 385K+ domains |
| Web admin UI | Full | Full | — | Dashboard | | Web admin UI | Full | Full | — | Dashboard |
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native | | Encrypted upstream (DoH/DoT) | Needs cloudflared | DoH only | DoT only | DoH + DoT (`tls://`) |
| Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) | | Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) |
| DoH server endpoint | — | Yes | — | Yes (RFC 8484) |
| Request hedging | — | — | — | All protocols (UDP, DoH, DoT) |
| Serve-stale + prefetch | — | — | Prefetch at 90% TTL | RFC 8767, prefetch at 90% TTL |
| Conditional forwarding | — | Yes | Yes | Yes (per-suffix rules) |
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows | | Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New | | Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
## Performance ## Performance
691ns cached round-trip. ~2.0M qps throughput. Zero heap allocations in the hot path. Recursive queries average 237ms after SRTT warmup (12x improvement over round-robin). ECDSA P-256 DNSSEC verification: 174ns. [Benchmarks →](bench/) 0.1ms cached queries — matches Unbound and AdGuard Home. Wire-level cache stores raw bytes with in-place TTL patching. Request hedging eliminates p99 spikes: cold recursive p99 538ms vs Unbound 748ms (28%), σ 4× tighter. [Benchmarks →](benches/)
## Learn More ## Learn More
@@ -135,11 +162,14 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
- [x] DNS forwarding, caching, ad blocking, developer overrides - [x] DNS forwarding, caching, ad blocking, developer overrides
- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy - [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy
- [x] LAN service discovery — mDNS, cross-machine DNS + proxy - [x] LAN service discovery — mDNS, cross-machine DNS + proxy
- [x] DNS-over-HTTPS — encrypted upstream - [x] DNS-over-HTTPS — encrypted upstream + server endpoint (RFC 8484)
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict) - [x] DNS-over-TLS — encrypted client listener (RFC 7858) + upstream forwarding (`tls://`)
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3 - [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
- [x] SRTT-based nameserver selection - [x] SRTT-based nameserver selection
- [x] Multi-forwarder failover — multiple upstreams with SRTT ranking, fallback pool - [x] Multi-forwarder failover — multiple upstreams with SRTT ranking, fallback pool
- [x] Request hedging — parallel requests rescue packet loss and tail latency (all protocols)
- [x] Serve-stale + prefetch — RFC 8767, background refresh at <10% TTL and on stale serve
- [x] Conditional forwarding — per-suffix rules for split-horizon DNS (Tailscale, VPNs)
- [x] Cache warming — proactive resolution for configured domains - [x] Cache warming — proactive resolution for configured domains
- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles - [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT - [ ] pkarr integration — self-sovereign DNS via Mainline DHT

View File

@@ -0,0 +1,30 @@
[server]
bind_addr = "127.0.0.1:5454"
api_port = 5381
api_bind_addr = "127.0.0.1"
data_dir = "/tmp/numa-bench"
[upstream]
mode = "recursive"
timeout_ms = 10000
[cache]
min_ttl = 60
max_ttl = 3600
[blocking]
enabled = false
[proxy]
port = 8080
tls_port = 8443
[dot]
enabled = true
port = 8530
[mobile]
enabled = false
[lan]
enabled = false

31
benches/numa-bench.toml Normal file
View File

@@ -0,0 +1,31 @@
[server]
bind_addr = "127.0.0.1:5454"
api_port = 5381
api_bind_addr = "127.0.0.1"
data_dir = "/tmp/numa-bench"
[upstream]
mode = "forward"
address = ["https://9.9.9.9/dns-query"]
timeout_ms = 10000
[cache]
min_ttl = 60
max_ttl = 3600
[blocking]
enabled = false
[proxy]
port = 8080
tls_port = 8443
[dot]
enabled = true
port = 8530
[mobile]
enabled = false
[lan]
enabled = false

1100
benches/recursive_compare.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
--- ---
title: I Built a DNS Resolver from Scratch in Rust 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. 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 date: 2026-03-20
--- ---
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? 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?

View File

@@ -1,7 +1,7 @@
--- ---
title: Implementing DNSSEC from Scratch in Rust title: Implementing DNSSEC from Scratch in Rust
description: Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries. description: Recursive resolution from root hints, chain-of-trust validation, NSEC/NSEC3 denial proofs, and what I learned implementing DNSSEC with zero DNS libraries.
date: March 2026 date: 2026-03-28
--- ---
In the [previous post](/blog/posts/dns-from-scratch.html) I covered how DNS works at the wire level — packet format, label compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed packets, did useful things locally, and relayed the rest to Cloudflare or Quad9. In the [previous post](/blog/posts/dns-from-scratch.html) I covered how DNS works at the wire level — packet format, label compression, TTL caching, DoH. Numa was a forwarding resolver: it parsed packets, did useful things locally, and relayed the rest to Cloudflare or Quad9.

View File

@@ -1,7 +1,7 @@
--- ---
title: DNS-over-TLS from Scratch in Rust title: DNS-over-TLS from Scratch in Rust
description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught. description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.
date: April 2026 date: 2026-04-06
--- ---
The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it. The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it.
@@ -169,7 +169,7 @@ I've been dogfooding this since v0.10 shipped in early April. The phone resolves
## What's next ## What's next
- **DoH server** — Numa already has a DoH client; the other half unlocks Firefox's built-in DoH setting pointing at Numa. - ~~**DoH server**~~shipped in v0.12.0. `POST /dns-query` accepts [RFC 8484](https://datatracker.ietf.org/doc/html/rfc8484) wire-format queries, so Firefox/Chrome can point their built-in DoH at Numa.
- **DoQ server (RFC 9250)** — DNS over QUIC. Android 14+ supports it natively. - **DoQ server (RFC 9250)** — DNS over QUIC. Android 14+ supports it natively.
- **DDR (RFC 9462)** — auto-discovery via `_dns.resolver.arpa IN SVCB`, so phones pick up a moved Numa instance without the installed profile going stale. - **DDR (RFC 9462)** — auto-discovery via `_dns.resolver.arpa IN SVCB`, so phones pick up a moved Numa instance without the installed profile going stale.

View File

@@ -0,0 +1,171 @@
---
title: Fixing DNS tail latency with a 5-line config and a 50-line function
description: Periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took cold recursive p99 from 2.3 seconds to 538ms.
date: 2026-04-12
---
If you're using reqwest for small HTTP/2 payloads, you probably have a tail latency problem you don't know about. Hyper's default flow control windows are 10,000× oversized for anything under 1 KB, and its dispatch channel adds periodic 40-140ms stalls that don't show up in median benchmarks.
I hit this building Numa's DoH forwarding path. Median was 10ms, mean was 23ms — the tail was dragging everything.
<div class="hero-metrics">
<div class="metric-card">
<div class="metric-vs">DoH forwarding p99</div>
<div class="metric-value">113 → 71ms</div>
<div class="metric-label">window tuning + request hedging</div>
</div>
<div class="metric-card">
<div class="metric-vs">Cold recursive p99</div>
<div class="metric-value">2.3s → 538ms</div>
<div class="metric-label">NS caching, serve-stale, parallel queries</div>
</div>
<div class="metric-card">
<div class="metric-vs">Forwarding σ</div>
<div class="metric-value">31 → 13ms</div>
<div class="metric-label">random spikes become parallel races</div>
</div>
</div>
The fix was a 5-line reqwest config and a 50-line hedging function. This post is also an advertisement for Dean & Barroso's 2013 paper ["The Tail at Scale"](https://research.google/pubs/pub40801/) — a decade-old idea that still demolishes dispatch spikes. The same ideas later took my cold recursive p99 from 2.3 seconds to 538ms.
---
## The cause: hyper's dispatch channel
Reqwest sits on top of hyper, which interposes an mpsc dispatch channel and a separate `ClientTask` between `.send()` and the h2 stream. I instrumented the forwarding path and confirmed: 100% of the spike time lives in the `send()` phase, and a parallel heartbeat task showed zero runtime lag during spikes. The tokio runtime was fine — the stall was internal to hyper's request scheduling.
Hickory-resolver doesn't have this issue. It holds `h2::SendRequest<Bytes>` directly and calls `ready().await; send_request()` in the caller's task — no channel, no scheduling dependency. I used it as a reference point throughout.
## Fix #1 — HTTP/2 window sizes
Reqwest inherits hyper's HTTP/2 defaults: 2 MB stream window, 5 MB connection window. For DNS responses (~200 bytes), that's ~10,000× oversized — unnecessary WINDOW_UPDATE frames, bloated bookkeeping on every poll, and different server-side scheduling behavior.
Setting both windows to the h2 spec default (64 KB) dropped my median from 13.3ms to 10.1ms:
```rust
reqwest::Client::builder()
.use_rustls_tls()
.http2_initial_stream_window_size(65_535)
.http2_initial_connection_window_size(65_535)
.http2_keep_alive_interval(Duration::from_secs(15))
.http2_keep_alive_while_idle(true)
.http2_keep_alive_timeout(Duration::from_secs(10))
.pool_idle_timeout(Duration::from_secs(300))
.pool_max_idle_per_host(1)
.build()
```
**Any Rust code using reqwest for tiny-payload HTTP/2 workloads — DoH, API polling, metric scraping — is probably hitting this.**
## Fix #2 — Request hedging
["The Tail at Scale"](https://research.google/pubs/pub40801/) (Dean & Barroso, 2013): fire a request, and if it doesn't return within your P50 latency, fire the same request in parallel. First response wins.
The intuition: if 5% of requests spike due to independent random events, two parallel requests means only 0.25% of pairs spike on *both*. The tail collapses.
**The surprise: hedging against the same upstream works.** HTTP/2 multiplexes streams — two `send_request()` calls on one connection become independent h2 streams. If one stalls in the dispatch channel, the other keeps making progress.
```rust
pub async fn forward_with_hedging_raw(
wire: &[u8],
primary: &Upstream,
secondary: &Upstream,
hedge_delay: Duration,
timeout_duration: Duration,
) -> Result<Vec<u8>> {
let primary_fut = forward_query_raw(wire, primary, timeout_duration);
tokio::pin!(primary_fut);
let delay = sleep(hedge_delay);
tokio::pin!(delay);
// Phase 1: wait for primary to return OR the hedge delay.
tokio::select! {
result = &mut primary_fut => return result,
_ = &mut delay => {}
}
// Phase 2: hedge delay expired — fire secondary, keep primary alive.
let secondary_fut = forward_query_raw(wire, secondary, timeout_duration);
tokio::pin!(secondary_fut);
// First successful response wins.
tokio::select! {
r = primary_fut => r,
r = secondary_fut => r,
}
}
```
The [production version](https://github.com/razvandimescu/numa/blob/main/src/forward.rs#L267) adds error handling — if one leg fails, it waits for the other. In production, Numa passes the same `&Upstream` twice when only one is configured. I extended hedging to all protocols — UDP (rescues packet loss on WiFi), DoT (rescues TLS handshake stalls). Configurable via `hedge_ms`; set to 0 to disable.
**Caveat: hedging hurts on degraded networks.** When latency is consistently high (no random spikes, just slow), the hedge adds overhead with nothing to rescue. Hedging is a variance reducer, not a latency reducer — it only helps when spikes are *random*.
---
## Forwarding results
5 iterations × 101 domains × 10 rounds, 5,050 samples per method. Hickory-resolver included as a reference (it uses h2 directly, no dispatch channel):
| | Single | **Hedged** | Hickory (ref) |
|---|---|---|---|
| mean | 17.4ms | **14.3ms** | 16.8ms |
| median | 10.4ms | **10.2ms** | 13.3ms |
| p95 | 52.5ms | **28.6ms** | 37.7ms |
| p99 | 113.4ms | **71.3ms** | 98.1ms |
| σ | 30.6ms | **13.2ms** | 19.1ms |
The internal improvement: hedging cut p95 by 45%, p99 by 37%, σ by 57%. The exact margin vs hickory varies with network conditions; the σ reduction is consistent across runs.
## Recursive resolution: from 2.3 seconds to 538ms
Forwarding is one job. Recursive resolution — walking from root hints through TLD nameservers to the authoritative server — is a different one. I started 15× behind Unbound on cold recursive p99 and traced it to four root causes.
**1. Missing NS delegation caching.** I cached glue records (ns1's IP) but not the delegation itself. Every `.com` query walked from root. Fix: cache NS records from referral authority sections. (10 lines)
**2. Expired cache entries caused full cold resolutions.** Fix: serve-stale ([RFC 8767](https://www.rfc-editor.org/rfc/rfc8767)) — return expired entries with TTL=1 while revalidating in the background. (20 lines)
**3. Wasting 1,900ms per unreachable server.** 800ms UDP timeout + unconditional 1,500ms TCP fallback. Fix: 400ms UDP, TCP only for truncation. (5 lines)
**4. Sequential NS queries on cold starts.** Fix: fire to the top 2 nameservers simultaneously. First response wins, SRTT recorded for both. Same hedging principle. (50 lines)
<div class="before-after">
<div class="ba-item">
<div class="ba-label">p99 before</div>
<div class="ba-value ba-before">2,367ms</div>
</div>
<div class="ba-arrow">&#8594;</div>
<div class="ba-item">
<div class="ba-label">p99 after</div>
<div class="ba-value ba-after">538ms</div>
</div>
<div class="ba-item ba-ref">
<div class="ba-label">Unbound (ref)</div>
<div class="ba-value">748ms</div>
</div>
</div>
Genuine cold benchmarks — unique subdomains, 1 query per domain, 5 iterations, 505 samples per server:
| | Baseline | Final | Unbound (ref) |
|---|---|---|---|
| p99 | 2,367ms | **538ms** | 748ms |
| σ | 254ms | **114ms** | 457ms |
| median | — | 77.6ms | 74.7ms |
Unbound wins median by ~4%. Where hedging shines is the tail — domains with slow or unreachable nameservers, where parallel queries turn worst-case sequential timeouts into races. Cache hits are tied at 0.1ms across Numa, Unbound, and AdGuard Home.
What I'm exploring next: persistent SRTT data across restarts (currently cold-starts lose all server timing), aggressive NSEC caching to shortcut negative lookups, and adaptive hedge delays that tune themselves to observed network conditions instead of a fixed 10ms.
---
## Takeaways
The real hero of this post is Dean & Barroso. Hedging works because **spikes are random, and two random draws rarely both lose**. It's effective for any HTTP/2 client, any language, any forwarder topology. Nobody we know of ships it by default.
If you're building a Rust service that makes many small HTTP/2 requests to the same backend: check your flow control window sizes first, then implement hedging. Don't rewrite the client.
Benchmarks are in [`benches/recursive_compare.rs`](https://github.com/razvandimescu/numa/blob/main/benches/recursive_compare.rs) — run them yourself. If you're using reqwest for tiny-payload workloads and try the window size fix, I'd love to hear if you see the same improvement.
---
Numa is a DNS resolver that runs on your laptop or phone. DoH, DoT, .numa local domains, ad blocking, developer overrides, a REST API, and all the optimization work in this post. [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa).

View File

@@ -15,9 +15,15 @@ api_port = 5380
# address = "9.9.9.9" # single upstream (plain UDP) # address = "9.9.9.9" # single upstream (plain UDP)
# address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest # address = ["192.168.1.1", "9.9.9.9:5353"] # multiple upstreams — SRTT picks fastest
# address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted) # address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
# address = "tls://9.9.9.9#dns.quad9.net" # DNS-over-TLS (encrypted, port 853)
# fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail # fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail
# port = 53 # default port for addresses without :port # port = 53 # default port for addresses without :port
# timeout_ms = 3000 # timeout_ms = 3000
# hedge_ms = 10 # request hedging delay (ms). After this delay
# # without a response, fires a parallel request
# # to the same upstream. Rescues packet loss (UDP),
# # dispatch spikes (DoH), TLS stalls (DoT).
# # Set to 0 to disable. Default: 10
# root_hints = [ # only used in recursive mode # root_hints = [ # only used in recursive mode
# "198.41.0.4", # a.root-servers.net (Verisign) # "198.41.0.4", # a.root-servers.net (Verisign)
# "199.9.14.201", # b.root-servers.net (USC-ISI) # "199.9.14.201", # b.root-servers.net (USC-ISI)
@@ -45,6 +51,22 @@ api_port = 5380
# "co", "br", "au", "ca", "jp", # other major ccTLDs # "co", "br", "au", "ca", "jp", # other major ccTLDs
# ] # ]
# [[forwarding]] # per-suffix conditional forwarding rules
# suffix = "168.192.in-addr.arpa" # single suffix → one upstream
# upstream = "100.90.1.63:5361"
#
# [[forwarding]]
# suffix = ["home.local", "home.arpa"] # multiple suffixes → same upstream
# upstream = "10.0.0.1" # port 53 default
#
# [[forwarding]] # DoT upstream: tls://IP[:port]#hostname
# suffix = ["google.com", "goog"] # hostname is the TLS SNI / cert name
# upstream = "tls://9.9.9.9#dns.quad9.net" # port 853 default
#
# [[forwarding]] # DoH upstream: full https:// URL
# suffix = "example.corp"
# upstream = "https://dns.quad9.net/dns-query"
# [blocking] # [blocking]
# enabled = true # set to false to disable ad blocking # enabled = true # set to false to disable ad blocking
# refresh_hours = 24 # refresh_hours = 24
@@ -52,7 +74,7 @@ api_port = 5380
# allowlist = ["example.com"] # domains to never block # allowlist = ["example.com"] # domains to never block
[cache] [cache]
max_entries = 10000 max_entries = 100000
min_ttl = 60 min_ttl = 60
max_ttl = 86400 max_ttl = 86400
# warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry # warm = ["google.com", "github.com"] # resolve at startup, refresh before TTL expiry

239
scripts/generate-blog-index.sh Executable file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env bash
set -euo pipefail
# Generate site/blog/index.html from blog/*.md frontmatter.
# Reads title, description, date from YAML frontmatter in each post.
# Sorts newest first (by date string — "April 2026" > "March 2026").
OUT="site/blog/index.html"
# Extract frontmatter fields from a markdown file
extract() {
local file="$1" field="$2"
sed -n '/^---$/,/^---$/p' "$file" | grep "^${field}:" | sed "s/^${field}: *//"
}
# Collect posts: "date|name|title|description" per line
posts=""
sources="blog/*.md"
if [ "${BLOG_INCLUDE_DRAFTS:-}" = "1" ] && ls drafts/*.md >/dev/null 2>&1; then
sources="blog/*.md drafts/*.md"
fi
for f in $sources; do
name=$(basename "$f" .md)
title=$(extract "$f" title)
desc=$(extract "$f" description)
date=$(extract "$f" date)
posts+="${date}|${name}|${title}|${desc}"$'\n'
done
# Sort by ISO date (YYYY-MM-DD), newest first
posts=$(echo "$posts" | grep -v '^$' | sort -t'|' -k1 -r)
# Format ISO date (YYYY-MM-DD) to "Month YYYY"
format_date() {
local months=(January February March April May June July August September October November December)
local y="${1%%-*}"
local m="${1#*-}"; m="${m%%-*}"; m=$((10#$m))
echo "${months[$((m-1))]} $y"
}
# Generate post list items
items=""
while IFS='|' read -r date name title desc; do
display_date=$(format_date "$date")
items+=" <li>
<a href=\"/blog/posts/${name}.html\">
<div class=\"post-title\">${title}</div>
<div class=\"post-desc\">${desc}</div>
<div class=\"post-date\">${display_date}</div>
</a>
</li>
"
done <<< "$posts"
# Write the full index.html — style matches the existing hand-maintained version
cat > "$OUT" << HTMLEOF
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blog — Numa</title>
<meta name="description" content="Technical writing about DNS, Rust, and building infrastructure from scratch.">
<link rel="stylesheet" href="/fonts/fonts.css">
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg-deep: #f5f0e8;
--bg-surface: #ece5da;
--bg-card: #faf7f2;
--amber: #c0623a;
--amber-dim: #9e4e2d;
--teal: #6b7c4e;
--text-primary: #2c2418;
--text-secondary: #6b5e4f;
--text-dim: #a39888;
--border: rgba(0, 0, 0, 0.08);
--font-display: 'Instrument Serif', Georgia, serif;
--font-body: 'DM Sans', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', monospace;
}
body {
background: var(--bg-deep);
color: var(--text-primary);
font-family: var(--font-body);
font-weight: 400;
line-height: 1.7;
-webkit-font-smoothing: antialiased;
}
body::before {
content: '';
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
pointer-events: none;
z-index: 9999;
}
.blog-nav {
padding: 1.5rem 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
}
.blog-nav a {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
text-decoration: none;
transition: color 0.2s;
}
.blog-nav a:hover { color: var(--amber); }
.blog-nav .wordmark {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 400;
color: var(--text-primary);
text-decoration: none;
text-transform: none;
letter-spacing: -0.02em;
}
.blog-nav .wordmark:hover { color: var(--amber); }
.blog-nav .sep {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.75rem;
}
.blog-index {
max-width: 720px;
margin: 0 auto;
padding: 3rem 2rem 6rem;
}
.blog-index h1 {
font-family: var(--font-display);
font-weight: 400;
font-size: 2.5rem;
margin-bottom: 3rem;
}
.post-list {
list-style: none;
}
.post-list li {
padding: 1.5rem 0;
border-bottom: 1px solid var(--border);
}
.post-list li:first-child {
border-top: 1px solid var(--border);
}
.post-list a {
text-decoration: none;
display: block;
}
.post-list .post-title {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 600;
color: var(--text-primary);
line-height: 1.3;
margin-bottom: 0.4rem;
transition: color 0.2s;
}
.post-list a:hover .post-title {
color: var(--amber);
}
.post-list .post-desc {
font-size: 0.95rem;
color: var(--text-secondary);
line-height: 1.5;
margin-bottom: 0.4rem;
}
.post-list .post-date {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
letter-spacing: 0.04em;
}
.blog-footer {
text-align: center;
padding: 3rem 2rem;
border-top: 1px solid var(--border);
max-width: 720px;
margin: 0 auto;
}
.blog-footer a {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
text-decoration: none;
margin: 0 1rem;
}
.blog-footer a:hover { color: var(--amber); }
</style>
</head>
<body>
<nav class="blog-nav">
<a href="/" class="wordmark">Numa</a>
<span class="sep">/</span>
<a href="/blog/">Blog</a>
</nav>
<main class="blog-index">
<h1>Blog</h1>
<ul class="post-list">
${items} </ul>
</main>
<footer class="blog-footer">
<a href="https://github.com/razvandimescu/numa">GitHub</a>
<a href="/">Home</a>
</footer>
</body>
</html>
HTMLEOF
echo " blog/index.html generated ($(echo "$posts" | wc -l | tr -d ' ') posts)"

14
scripts/serve-site.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${1:-9000}"
if [[ "${1:-}" == "--drafts" ]] || [[ "${2:-}" == "--drafts" ]]; then
PORT="${PORT//--drafts/9000}" # default port if --drafts was first arg
make blog-drafts
else
make blog
fi
echo "Serving site at http://localhost:$PORT"
cd site && python3 -m http.server "$PORT"

View File

@@ -267,9 +267,105 @@ body::before {
.blog-footer a:hover { color: var(--amber); } .blog-footer a:hover { color: var(--amber); }
/* --- Responsive --- */ /* --- Responsive --- */
/* Hero metrics cards */
.hero-metrics {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin: 2rem 0;
}
.metric-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 1.25rem;
text-align: center;
}
.metric-vs {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 0.5rem;
}
.metric-value {
font-family: var(--font-display);
font-size: 2.4rem;
font-weight: 400;
color: var(--amber);
line-height: 1.1;
}
.metric-label {
font-size: 0.82rem;
color: var(--text-secondary);
margin-top: 0.5rem;
line-height: 1.3;
}
/* Before/after progression */
.before-after {
display: flex;
align-items: center;
justify-content: center;
gap: 1.5rem;
margin: 2rem 0;
padding: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
}
.ba-item { text-align: center; }
.ba-label {
font-family: var(--font-mono);
font-size: 0.7rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
margin-bottom: 0.3rem;
}
.ba-value {
font-family: var(--font-display);
font-size: 1.8rem;
font-weight: 400;
color: var(--text-secondary);
}
.ba-before {
text-decoration: line-through;
text-decoration-color: rgba(192, 98, 58, 0.4);
color: var(--text-dim);
}
.ba-after { color: var(--amber); }
.ba-arrow { font-size: 1.5rem; color: var(--text-dim); }
.ba-ref {
border-left: 1px solid var(--border);
padding-left: 1.5rem;
}
/* Spike highlight */
.spike {
background: rgba(192, 98, 58, 0.12);
padding: 0.15em 0.5em;
border-radius: 3px;
font-weight: 600;
color: var(--amber-dim);
}
/* Section dividers */
.article hr {
border: none;
height: 1px;
background: var(--border);
margin: 3rem auto;
max-width: 120px;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.article { padding: 2rem 1.25rem 4rem; } .article { padding: 2rem 1.25rem 4rem; }
.article pre { padding: 1rem; margin-left: -0.5rem; margin-right: -0.5rem; border-radius: 0; border-left: none; border-right: none; } .article pre { padding: 1rem; margin-left: -0.5rem; margin-right: -0.5rem; border-radius: 0; border-left: none; border-right: none; }
.hero-metrics { grid-template-columns: 1fr; }
.before-after { flex-direction: column; gap: 0.75rem; }
.ba-ref { border-left: none; border-top: 1px solid var(--border); padding-left: 0; padding-top: 0.75rem; }
} }
</style> </style>
</head> </head>

View File

@@ -168,10 +168,17 @@ body::before {
<main class="blog-index"> <main class="blog-index">
<h1>Blog</h1> <h1>Blog</h1>
<ul class="post-list"> <ul class="post-list">
<li>
<a href="/blog/posts/fixing-doh-tail-latency.html">
<div class="post-title">Fixing DNS tail latency with a 5-line config and a 50-line function</div>
<div class="post-desc">Periodic 40-140ms DoH spikes from hyper's dispatch channel. The fix was reqwest window tuning and request hedging — Dean & Barroso's "The Tail at Scale," applied to a DNS forwarder. Same ideas took cold recursive p99 from 2.3 seconds to 538ms.</div>
<div class="post-date">April 2026</div>
</a>
</li>
<li> <li>
<a href="/blog/posts/dot-from-scratch.html"> <a href="/blog/posts/dot-from-scratch.html">
<div class="post-title">DNS-over-TLS from Scratch in Rust</div> <div class="post-title">DNS-over-TLS from Scratch in Rust</div>
<div class="post-desc">Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, iPhone dogfooding, and two bugs that only the strict clients caught.</div> <div class="post-desc">Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.</div>
<div class="post-date">April 2026</div> <div class="post-date">April 2026</div>
</a> </a>
</li> </li>
@@ -185,7 +192,7 @@ body::before {
<li> <li>
<a href="/blog/posts/dns-from-scratch.html"> <a href="/blog/posts/dns-from-scratch.html">
<div class="post-title">I Built a DNS Resolver from Scratch in Rust</div> <div class="post-title">I Built a DNS Resolver from Scratch in Rust</div>
<div class="post-desc">How DNS actually works at the wire level — label compression, TTL tricks, DoH implementation, and what I learned building a resolver with zero DNS libraries.</div> <div class="post-desc">How DNS actually works at the wire level — label compression, TTL tricks, DoH, and what surprised me building a resolver with zero DNS libraries.</div>
<div class="post-date">March 2026</div> <div class="post-date">March 2026</div>
</a> </a>
</li> </li>

View File

@@ -223,6 +223,10 @@ body {
.path-bar-fill.override { background: var(--emerald); } .path-bar-fill.override { background: var(--emerald); }
.path-bar-fill.error { background: var(--rose); } .path-bar-fill.error { background: var(--rose); }
.path-bar-fill.blocked { background: var(--text-dim); } .path-bar-fill.blocked { background: var(--text-dim); }
.path-bar-fill.udp { background: var(--text-dim); }
.path-bar-fill.tcp { background: var(--violet); }
.path-bar-fill.dot { background: var(--emerald); }
.path-bar-fill.doh { background: var(--teal); }
.path-pct { .path-pct {
font-family: var(--font-mono); font-family: var(--font-mono);
font-size: 0.75rem; font-size: 0.75rem;
@@ -288,6 +292,10 @@ body {
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); } .path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); } .path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); } .path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
.path-tag.UDP { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
.path-tag.TCP { background: rgba(100, 116, 139, 0.12); color: var(--violet-dim); }
.path-tag.DOT { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
.path-tag.DOH { background: rgba(107, 124, 78, 0.12); color: var(--teal); }
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; } .src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
/* Sidebar panels */ /* Sidebar panels */
@@ -622,6 +630,16 @@ body {
</div> </div>
</div> </div>
<!-- Transport breakdown -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">Transport</span>
<span class="panel-title" id="transportEncrypted" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="transportBars">
</div>
</div>
<!-- Main grid: query log + sidebar --> <!-- Main grid: query log + sidebar -->
<div class="main-grid"> <div class="main-grid">
<!-- Query log --> <!-- Query log -->
@@ -643,6 +661,14 @@ body {
<option value="LOCAL">local</option> <option value="LOCAL">local</option>
<option value="SERVFAIL">error</option> <option value="SERVFAIL">error</option>
</select> </select>
<select id="logFilterTransport" onchange="applyLogFilter()"
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
<option value="">all transports</option>
<option value="UDP">UDP</option>
<option value="TCP">TCP</option>
<option value="DOT">DoT</option>
<option value="DOH">DoH</option>
</select>
<span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span> <span class="panel-title" id="queryCount" style="color: var(--text-dim)"></span>
</div> </div>
</div> </div>
@@ -654,6 +680,7 @@ body {
<th>Type</th> <th>Type</th>
<th>Domain</th> <th>Domain</th>
<th>Path</th> <th>Path</th>
<th>Transport</th>
<th>Result</th> <th>Result</th>
<th>Latency</th> <th>Latency</th>
</tr> </tr>
@@ -907,6 +934,27 @@ function renderMemory(mem, stats) {
`; `;
} }
function renderBarChart(containerId, defs, data, total) {
total = total || 1;
document.getElementById(containerId).innerHTML = defs.map(d => {
const count = data[d.key] || 0;
const pct = ((count / total) * 100).toFixed(1);
return `
<div class="path-bar-row">
<span class="path-label">${d.label}</span>
<div class="path-bar-track">
<div class="path-bar-fill ${d.cls}" style="width: ${pct}%"></div>
</div>
<span class="path-pct">${pct}%</span>
</div>`;
}).join('');
}
function encryptionPct(transport) {
const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
return (((transport.dot + transport.doh) / total) * 100).toFixed(0);
}
const PATH_DEFS = [ const PATH_DEFS = [
{ key: 'forwarded', label: 'Forward', cls: 'forward' }, { key: 'forwarded', label: 'Forward', cls: 'forward' },
{ key: 'recursive', label: 'Recursive', cls: 'recursive' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' },
@@ -918,20 +966,23 @@ const PATH_DEFS = [
]; ];
function renderPaths(queries) { function renderPaths(queries) {
const total = queries.total || 1; renderBarChart('pathBars', PATH_DEFS, queries, queries.total);
const container = document.getElementById('pathBars'); }
container.innerHTML = PATH_DEFS.map(p => {
const count = queries[p.key] || 0; const TRANSPORT_DEFS = [
const pct = ((count / total) * 100).toFixed(1); { key: 'udp', label: 'UDP', cls: 'udp' },
return ` { key: 'tcp', label: 'TCP', cls: 'tcp' },
<div class="path-bar-row"> { key: 'dot', label: 'DoT', cls: 'dot' },
<span class="path-label">${p.label}</span> { key: 'doh', label: 'DoH', cls: 'doh' },
<div class="path-bar-track"> ];
<div class="path-bar-fill ${p.cls}" style="width: ${pct}%"></div>
</div> function renderTransport(transport) {
<span class="path-pct">${pct}%</span> const total = (transport.udp + transport.tcp + transport.dot + transport.doh) || 1;
</div>`; renderBarChart('transportBars', TRANSPORT_DEFS, transport, total);
}).join(''); const encPct = encryptionPct(transport);
const el = document.getElementById('transportEncrypted');
el.textContent = `${encPct}% encrypted`;
el.style.color = encPct >= 80 ? 'var(--emerald)' : encPct >= 50 ? 'var(--amber)' : 'var(--rose)';
} }
function renderQueryLog(entries) { function renderQueryLog(entries) {
@@ -942,6 +993,7 @@ function renderQueryLog(entries) {
function applyLogFilter() { function applyLogFilter() {
const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase(); const domainFilter = document.getElementById('logFilterDomain').value.trim().toLowerCase();
const pathFilter = document.getElementById('logFilterPath').value; const pathFilter = document.getElementById('logFilterPath').value;
const transportFilter = document.getElementById('logFilterTransport').value;
let filtered = lastLogEntries; let filtered = lastLogEntries;
if (domainFilter) { if (domainFilter) {
@@ -950,6 +1002,9 @@ function applyLogFilter() {
if (pathFilter) { if (pathFilter) {
filtered = filtered.filter(e => e.path === pathFilter); filtered = filtered.filter(e => e.path === pathFilter);
} }
if (transportFilter) {
filtered = filtered.filter(e => e.transport === transportFilter);
}
const tbody = document.getElementById('queryLogBody'); const tbody = document.getElementById('queryLogBody');
document.getElementById('queryCount').textContent = document.getElementById('queryCount').textContent =
@@ -967,6 +1022,7 @@ function applyLogFilter() {
<td>${e.query_type}</td> <td>${e.query_type}</td>
<td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td> <td class="domain-cell" title="${e.domain}">${e.domain}${allowBtn}</td>
<td><span class="path-tag ${e.path}">${e.path}</span></td> <td><span class="path-tag ${e.path}">${e.path}</span></td>
<td><span class="path-tag ${e.transport}">${e.transport}</span></td>
<td style="white-space:nowrap;"><span style="display:inline-block;width:15px;text-align:center;">${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}</span>${e.rescode}</td> <td style="white-space:nowrap;"><span style="display:inline-block;width:15px;text-align:center;">${e.dnssec === 'secure' ? '<svg title="DNSSEC verified" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--emerald)" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="m9 12 2 2 4-4"/></svg>' : ''}</span>${e.rescode}</td>
<td>${e.latency_ms.toFixed(1)}ms</td> <td>${e.latency_ms.toFixed(1)}ms</td>
</tr>`; </tr>`;
@@ -1141,11 +1197,13 @@ async function refresh() {
// QPS calculation // QPS calculation
const now = Date.now(); const now = Date.now();
const encPct = encryptionPct(stats.transport);
if (prevTotal !== null && prevTime !== null) { if (prevTotal !== null && prevTime !== null) {
const dt = (now - prevTime) / 1000; const dt = (now - prevTime) / 1000;
const dq = q.total - prevTotal; const dq = q.total - prevTotal;
const qps = dt > 0 ? (dq / dt).toFixed(1) : '0.0'; const qps = dt > 0 ? (dq / dt).toFixed(1) : '0.0';
document.getElementById('qps').textContent = `~${qps}/s`; const encTag = q.total > 0 ? ` · ${encPct}% enc` : '';
document.getElementById('qps').textContent = `~${qps}/s${encTag}`;
} }
prevTotal = q.total; prevTotal = q.total;
prevTime = now; prevTime = now;
@@ -1157,6 +1215,7 @@ async function refresh() {
// Panels // Panels
renderPaths(q); renderPaths(q);
renderTransport(stats.transport);
renderQueryLog(logs); renderQueryLog(logs);
renderOverrides(overrides); renderOverrides(overrides);
renderCache(cache); renderCache(cache);

View File

@@ -152,6 +152,7 @@ struct QueryLogResponse {
domain: String, domain: String,
query_type: String, query_type: String,
path: String, path: String,
transport: String,
rescode: String, rescode: String,
latency_ms: f64, latency_ms: f64,
dnssec: String, dnssec: String,
@@ -167,6 +168,7 @@ struct StatsResponse {
dnssec: bool, dnssec: bool,
srtt: bool, srtt: bool,
queries: QueriesStats, queries: QueriesStats,
transport: TransportStats,
cache: CacheStats, cache: CacheStats,
overrides: OverrideStats, overrides: OverrideStats,
blocking: BlockingStatsResponse, blocking: BlockingStatsResponse,
@@ -175,6 +177,14 @@ struct StatsResponse {
memory: MemoryStats, memory: MemoryStats,
} }
#[derive(Serialize)]
struct TransportStats {
udp: u64,
tcp: u64,
dot: u64,
doh: u64,
}
#[derive(Serialize)] #[derive(Serialize)]
struct MobileStatsResponse { struct MobileStatsResponse {
enabled: bool, enabled: bool,
@@ -483,6 +493,7 @@ async fn query_log(
domain: e.domain.clone(), domain: e.domain.clone(),
query_type: e.query_type.as_str().to_string(), query_type: e.query_type.as_str().to_string(),
path: e.path.as_str().to_string(), path: e.path.as_str().to_string(),
transport: e.transport.as_str().to_string(),
rescode: e.rescode.as_str().to_string(), rescode: e.rescode.as_str().to_string(),
latency_ms: e.latency_us as f64 / 1000.0, latency_ms: e.latency_us as f64 / 1000.0,
dnssec: e.dnssec.as_str().to_string(), dnssec: e.dnssec.as_str().to_string(),
@@ -545,6 +556,12 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
blocked: snap.blocked, blocked: snap.blocked,
errors: snap.errors, errors: snap.errors,
}, },
transport: TransportStats {
udp: snap.transport_udp,
tcp: snap.transport_tcp,
dot: snap.transport_dot,
doh: snap.transport_doh,
},
cache: CacheStats { cache: CacheStats {
entries: cache_len, entries: cache_len,
max_entries: cache_max, max_entries: cache_max,
@@ -1003,51 +1020,10 @@ mod tests {
use super::*; use super::*;
use axum::body::Body; use axum::body::Body;
use http::Request; use http::Request;
use std::sync::{Mutex, RwLock};
use tower::ServiceExt; use tower::ServiceExt;
async fn test_ctx() -> Arc<ServerCtx> { async fn test_ctx() -> Arc<ServerCtx> {
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); Arc::new(crate::testutil::test_ctx().await)
Arc::new(ServerCtx {
socket,
zone_map: std::collections::HashMap::new(),
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
stats: Mutex::new(crate::stats::ServerStats::new()),
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
services: Mutex::new(crate::service_store::ServiceStore::new()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
forwarding_rules: Vec::new(),
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
vec![crate::forward::Upstream::Udp(
"127.0.0.1:53".parse().unwrap(),
)],
vec![],
)),
upstream_auto: false,
upstream_port: 53,
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
timeout: std::time::Duration::from_secs(3),
proxy_tld: "numa".to_string(),
proxy_tld_suffix: ".numa".to_string(),
lan_enabled: false,
config_path: "/tmp/test-numa.toml".to_string(),
config_found: false,
config_dir: std::path::PathBuf::from("/tmp"),
data_dir: std::path::PathBuf::from("/tmp"),
tls_config: None,
upstream_mode: crate::config::UpstreamMode::Forward,
root_hints: Vec::new(),
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
inflight: Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: false,
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
mobile_enabled: false,
mobile_port: 8765,
})
} }
#[tokio::test] #[tokio::test]

View File

@@ -1,9 +1,26 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::buffer::BytePacketBuffer;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::question::QueryType; use crate::question::QueryType;
use crate::record::DnsRecord; use crate::wire::WireMeta;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Freshness {
/// Within TTL, no action needed.
Fresh,
/// Within TTL but <10% remaining — trigger background prefetch.
NearExpiry,
/// Past TTL but within stale window — serve with TTL=1, trigger background refresh.
Stale,
}
impl Freshness {
pub fn needs_refresh(self) -> bool {
matches!(self, Freshness::NearExpiry | Freshness::Stale)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DnssecStatus { pub enum DnssecStatus {
@@ -26,14 +43,16 @@ impl DnssecStatus {
} }
struct CacheEntry { struct CacheEntry {
packet: DnsPacket, wire: Vec<u8>,
meta: WireMeta,
inserted_at: Instant, inserted_at: Instant,
ttl: Duration, ttl: Duration,
dnssec_status: DnssecStatus, dnssec_status: DnssecStatus,
} }
/// DNS cache using a two-level map (domain -> query_type -> entry) so that const STALE_WINDOW: Duration = Duration::from_secs(3600);
/// lookups can borrow `&str` instead of allocating a `String` key.
/// DNS cache with serve-stale (RFC 8767). Stores raw wire bytes.
pub struct DnsCache { pub struct DnsCache {
entries: HashMap<String, HashMap<QueryType, CacheEntry>>, entries: HashMap<String, HashMap<QueryType, CacheEntry>>,
entry_count: usize, entry_count: usize,
@@ -53,33 +72,118 @@ impl DnsCache {
} }
} }
/// Look up cached wire bytes, patching ID and TTLs in the returned copy.
/// Implements serve-stale (RFC 8767): expired entries within STALE_WINDOW
/// are returned with TTL=1 and `stale=true` so callers can revalidate.
pub fn lookup_wire(
&self,
domain: &str,
qtype: QueryType,
new_id: u16,
) -> Option<(Vec<u8>, DnssecStatus, Freshness)> {
let type_map = self.entries.get(domain)?;
let entry = type_map.get(&qtype)?;
let elapsed = entry.inserted_at.elapsed();
let (remaining, freshness) = if elapsed < entry.ttl {
let secs = (entry.ttl - elapsed).as_secs() as u32;
let f = if elapsed * 10 >= entry.ttl * 9 {
Freshness::NearExpiry
} else {
Freshness::Fresh
};
(secs.max(1), f)
} else if elapsed < entry.ttl + STALE_WINDOW {
(1, Freshness::Stale)
} else {
return None;
};
let mut wire = entry.wire.clone();
crate::wire::patch_id(&mut wire, new_id);
crate::wire::patch_ttls(&mut wire, &entry.meta.ttl_offsets, remaining);
Some((wire, entry.dnssec_status, freshness))
}
pub fn insert_wire(
&mut self,
domain: &str,
qtype: QueryType,
wire: &[u8],
dnssec_status: DnssecStatus,
) {
let meta = match crate::wire::scan_ttl_offsets(wire) {
Ok(m) => m,
Err(_) => return, // malformed wire, skip
};
if self.entry_count >= self.max_entries {
self.evict_expired();
if self.entry_count >= self.max_entries {
self.evict_stalest();
}
}
let min_ttl = crate::wire::min_ttl_from_wire(wire, &meta)
.unwrap_or(self.min_ttl)
.clamp(self.min_ttl, self.max_ttl);
let type_map = if let Some(existing) = self.entries.get_mut(domain) {
existing
} else {
self.entries.entry(domain.to_string()).or_default()
};
if !type_map.contains_key(&qtype) {
self.entry_count += 1;
}
type_map.insert(
qtype,
CacheEntry {
wire: wire.to_vec(),
meta,
inserted_at: Instant::now(),
ttl: Duration::from_secs(min_ttl as u64),
dnssec_status,
},
);
}
/// Read-only lookup — expired entries are left in place (cleaned up on insert). /// Read-only lookup — expired entries are left in place (cleaned up on insert).
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> { pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt) self.lookup_with_status(domain, qtype)
.map(|(pkt, _, _)| pkt)
} }
pub fn lookup_with_status( pub fn lookup_with_status(
&self, &self,
domain: &str, domain: &str,
qtype: QueryType, qtype: QueryType,
) -> Option<(DnsPacket, DnssecStatus)> { ) -> Option<(DnsPacket, DnssecStatus, Freshness)> {
let type_map = self.entries.get(domain)?; let (wire, status, freshness) = self.lookup_wire(domain, qtype, 0)?;
let entry = type_map.get(&qtype)?; let mut buf = BytePacketBuffer::from_bytes(&wire);
let pkt = DnsPacket::from_buffer(&mut buf).ok()?;
Some((pkt, status, freshness))
}
let elapsed = entry.inserted_at.elapsed(); pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
if elapsed >= entry.ttl { self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
return None; }
pub fn insert_with_status(
&mut self,
domain: &str,
qtype: QueryType,
packet: &DnsPacket,
dnssec_status: DnssecStatus,
) {
let mut buf = BytePacketBuffer::new();
if packet.write(&mut buf).is_err() {
return;
} }
self.insert_wire(domain, qtype, buf.filled(), dnssec_status);
let remaining_secs = (entry.ttl - elapsed).as_secs() as u32;
let remaining = remaining_secs.max(1);
let mut packet = entry.packet.clone();
adjust_ttls(&mut packet.answers, remaining);
adjust_ttls(&mut packet.authorities, remaining);
adjust_ttls(&mut packet.resources, remaining);
Some((packet, entry.dnssec_status))
} }
pub fn ttl_remaining(&self, domain: &str, qtype: QueryType) -> Option<(u32, u32)> { pub fn ttl_remaining(&self, domain: &str, qtype: QueryType) -> Option<(u32, u32)> {
@@ -105,49 +209,6 @@ impl DnsCache {
false false
} }
pub fn insert(&mut self, domain: &str, qtype: QueryType, packet: &DnsPacket) {
self.insert_with_status(domain, qtype, packet, DnssecStatus::Indeterminate);
}
pub fn insert_with_status(
&mut self,
domain: &str,
qtype: QueryType,
packet: &DnsPacket,
dnssec_status: DnssecStatus,
) {
if self.entry_count >= self.max_entries {
self.evict_expired();
if self.entry_count >= self.max_entries {
return;
}
}
let min_ttl = extract_min_ttl(&packet.answers)
.unwrap_or(self.min_ttl)
.clamp(self.min_ttl, self.max_ttl);
let type_map = if let Some(existing) = self.entries.get_mut(domain) {
existing
} else {
self.entries.entry(domain.to_string()).or_default()
};
if !type_map.contains_key(&qtype) {
self.entry_count += 1;
}
type_map.insert(
qtype,
CacheEntry {
packet: packet.clone(),
inserted_at: Instant::now(),
ttl: Duration::from_secs(min_ttl as u64),
dnssec_status,
},
);
}
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.entry_count self.entry_count
} }
@@ -179,7 +240,8 @@ impl DnsCache {
+ 1; + 1;
total += type_map.capacity() * inner_slot; total += type_map.capacity() * inner_slot;
for entry in type_map.values() { for entry in type_map.values() {
total += entry.packet.heap_bytes(); total += entry.wire.capacity()
+ entry.meta.ttl_offsets.capacity() * std::mem::size_of::<usize>();
} }
} }
total total
@@ -220,6 +282,34 @@ impl DnsCache {
}); });
self.entry_count -= count; self.entry_count -= count;
} }
/// Evict the single entry closest to (or furthest past) expiry.
fn evict_stalest(&mut self) {
let mut worst: Option<(String, QueryType, Duration)> = None;
for (domain, type_map) in &self.entries {
for (qtype, entry) in type_map {
let age = entry.inserted_at.elapsed();
let remaining = entry.ttl.saturating_sub(age);
match &worst {
None => worst = Some((domain.clone(), *qtype, remaining)),
Some((_, _, w)) if remaining < *w => {
worst = Some((domain.clone(), *qtype, remaining));
}
_ => {}
}
}
}
if let Some((domain, qtype, _)) = worst {
if let Some(type_map) = self.entries.get_mut(&domain) {
if type_map.remove(&qtype).is_some() {
self.entry_count -= 1;
}
if type_map.is_empty() {
self.entries.remove(&domain);
}
}
}
}
} }
pub struct CacheInfo { pub struct CacheInfo {
@@ -228,20 +318,11 @@ pub struct CacheInfo {
pub ttl_remaining: u32, pub ttl_remaining: u32,
} }
fn extract_min_ttl(records: &[DnsRecord]) -> Option<u32> {
records.iter().map(|r| r.ttl()).min()
}
fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) {
for record in records.iter_mut() {
record.set_ttl(new_ttl);
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::record::DnsRecord;
#[test] #[test]
fn heap_bytes_grows_with_entries() { fn heap_bytes_grows_with_entries() {

View File

@@ -33,6 +33,39 @@ pub struct Config {
pub dot: DotConfig, pub dot: DotConfig,
#[serde(default)] #[serde(default)]
pub mobile: MobileConfig, pub mobile: MobileConfig,
#[serde(default)]
pub forwarding: Vec<ForwardingRuleConfig>,
}
#[derive(Deserialize, Clone, Debug)]
pub struct ForwardingRuleConfig {
#[serde(deserialize_with = "string_or_vec")]
pub suffix: Vec<String>,
pub upstream: String,
}
impl ForwardingRuleConfig {
fn to_runtime_rules(&self) -> Result<Vec<crate::system_dns::ForwardingRule>> {
let upstream = crate::forward::parse_upstream(&self.upstream, 53)
.map_err(|e| format!("forwarding rule for upstream '{}': {}", self.upstream, e))?;
Ok(self
.suffix
.iter()
.map(|s| crate::system_dns::ForwardingRule::new(s.clone(), upstream.clone()))
.collect())
}
}
pub fn merge_forwarding_rules(
config_rules: &[ForwardingRuleConfig],
discovered: Vec<crate::system_dns::ForwardingRule>,
) -> Result<Vec<crate::system_dns::ForwardingRule>> {
let mut merged: Vec<crate::system_dns::ForwardingRule> = Vec::new();
for rule in config_rules {
merged.extend(rule.to_runtime_rules()?);
}
merged.extend(discovered);
Ok(merged)
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -105,6 +138,8 @@ pub struct UpstreamConfig {
pub fallback: Vec<String>, pub fallback: Vec<String>,
#[serde(default = "default_timeout_ms")] #[serde(default = "default_timeout_ms")]
pub timeout_ms: u64, pub timeout_ms: u64,
#[serde(default = "default_hedge_ms")]
pub hedge_ms: u64,
#[serde(default = "default_root_hints")] #[serde(default = "default_root_hints")]
pub root_hints: Vec<String>, pub root_hints: Vec<String>,
#[serde(default = "default_prime_tlds")] #[serde(default = "default_prime_tlds")]
@@ -121,6 +156,7 @@ impl Default for UpstreamConfig {
port: default_upstream_port(), port: default_upstream_port(),
fallback: Vec::new(), fallback: Vec::new(),
timeout_ms: default_timeout_ms(), timeout_ms: default_timeout_ms(),
hedge_ms: default_hedge_ms(),
root_hints: default_root_hints(), root_hints: default_root_hints(),
prime_tlds: default_prime_tlds(), prime_tlds: default_prime_tlds(),
srtt: default_srtt(), srtt: default_srtt(),
@@ -238,6 +274,9 @@ fn default_upstream_port() -> u16 {
fn default_timeout_ms() -> u64 { fn default_timeout_ms() -> u64 {
5000 5000
} }
fn default_hedge_ms() -> u64 {
10
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct CacheConfig { pub struct CacheConfig {
@@ -263,7 +302,7 @@ impl Default for CacheConfig {
} }
fn default_max_entries() -> usize { fn default_max_entries() -> usize {
10000 100_000
} }
fn default_min_ttl() -> u32 { fn default_min_ttl() -> u32 {
60 60
@@ -585,6 +624,193 @@ mod tests {
assert!(config.upstream.address.is_empty()); assert!(config.upstream.address.is_empty());
assert!(config.upstream.fallback.is_empty()); assert!(config.upstream.fallback.is_empty());
} }
// ── issue #82: [[forwarding]] config section ────────────────────────
#[test]
fn forwarding_empty_by_default() {
let config: Config = toml::from_str("").unwrap();
assert!(config.forwarding.is_empty());
}
#[test]
fn forwarding_parses_single_rule() {
let toml = r#"
[[forwarding]]
suffix = "home.local"
upstream = "100.90.1.63:5361"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1);
assert_eq!(config.forwarding[0].suffix, &["home.local"]);
assert_eq!(config.forwarding[0].upstream, "100.90.1.63:5361");
}
#[test]
fn forwarding_parses_reverse_dns_zone() {
let toml = r#"
[[forwarding]]
suffix = "168.192.in-addr.arpa"
upstream = "100.90.1.63:5361"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1);
assert_eq!(config.forwarding[0].suffix, &["168.192.in-addr.arpa"]);
}
#[test]
fn forwarding_parses_multiple_rules() {
let toml = r#"
[[forwarding]]
suffix = "168.192.in-addr.arpa"
upstream = "100.90.1.63:5361"
[[forwarding]]
suffix = "home.local"
upstream = "10.0.0.1"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 2);
assert_eq!(config.forwarding[1].upstream, "10.0.0.1");
}
#[test]
fn forwarding_parses_suffix_array() {
let toml = r#"
[[forwarding]]
suffix = ["168.192.in-addr.arpa", "onsite"]
upstream = "192.168.88.1"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.forwarding.len(), 1);
assert_eq!(
config.forwarding[0].suffix,
&["168.192.in-addr.arpa", "onsite"]
);
}
#[test]
fn forwarding_suffix_array_expands_to_multiple_runtime_rules() {
let rule = ForwardingRuleConfig {
suffix: vec!["168.192.in-addr.arpa".to_string(), "onsite".to_string()],
upstream: "192.168.88.1".to_string(),
};
let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime.len(), 2);
assert_eq!(runtime[0].suffix, "168.192.in-addr.arpa");
assert_eq!(runtime[1].suffix, "onsite");
assert_eq!(runtime[0].upstream, runtime[1].upstream);
}
#[test]
fn forwarding_upstream_with_explicit_port() {
let rule = ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "100.90.1.63:5361".to_string(),
};
let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime.len(), 1);
assert!(matches!(
runtime[0].upstream,
crate::forward::Upstream::Udp(_)
));
assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:5361");
assert_eq!(runtime[0].suffix, "home.local");
}
#[test]
fn forwarding_upstream_defaults_to_port_53() {
let rule = ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "100.90.1.63".to_string(),
};
let runtime = rule.to_runtime_rules().unwrap();
assert_eq!(runtime[0].upstream.to_string(), "100.90.1.63:53");
}
#[test]
fn forwarding_invalid_upstream_returns_error() {
let rule = ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "not-a-valid-host".to_string(),
};
assert!(rule.to_runtime_rules().is_err());
}
#[test]
fn forwarding_upstream_accepts_dot_scheme() {
let rule = ForwardingRuleConfig {
suffix: vec!["google.com".to_string()],
upstream: "tls://9.9.9.9#dns.quad9.net".to_string(),
};
let runtime = rule
.to_runtime_rules()
.expect("tls:// upstream should parse");
assert_eq!(runtime.len(), 1);
assert_eq!(
runtime[0].upstream.to_string(),
"tls://9.9.9.9:853#dns.quad9.net"
);
}
#[test]
fn forwarding_upstream_accepts_doh_scheme() {
let rule = ForwardingRuleConfig {
suffix: vec!["goog".to_string()],
upstream: "https://dns.quad9.net/dns-query".to_string(),
};
let runtime = rule
.to_runtime_rules()
.expect("https:// upstream should parse");
assert_eq!(runtime.len(), 1);
assert_eq!(
runtime[0].upstream.to_string(),
"https://dns.quad9.net/dns-query"
);
}
#[test]
fn forwarding_config_rules_take_precedence_over_discovered() {
let config_rules = vec![ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "10.0.0.1:53".to_string(),
}];
let discovered = vec![crate::system_dns::ForwardingRule::new(
"home.local".to_string(),
crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()),
)];
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
let picked = crate::system_dns::match_forwarding_rule("host.home.local", &merged)
.expect("rule should match");
assert_eq!(picked.to_string(), "10.0.0.1:53");
}
#[test]
fn forwarding_merge_preserves_non_overlapping_discovered() {
let config_rules = vec![ForwardingRuleConfig {
suffix: vec!["home.local".to_string()],
upstream: "10.0.0.1:53".to_string(),
}];
let discovered = vec![crate::system_dns::ForwardingRule::new(
"corp.example".to_string(),
crate::forward::Upstream::Udp("192.168.1.1:53".parse().unwrap()),
)];
let merged = merge_forwarding_rules(&config_rules, discovered).unwrap();
assert_eq!(merged.len(), 2);
let picked = crate::system_dns::match_forwarding_rule("host.corp.example", &merged)
.expect("discovered rule should still match");
assert_eq!(picked.to_string(), "192.168.1.1:53");
}
#[test]
fn forwarding_merge_suffix_array_expands_to_multiple_rules() {
let config_rules = vec![ForwardingRuleConfig {
suffix: vec!["a.local".to_string(), "b.local".to_string()],
upstream: "10.0.0.1:53".to_string(),
}];
let merged = merge_forwarding_rules(&config_rules, vec![]).unwrap();
assert_eq!(merged.len(), 2);
}
} }
pub struct ConfigLoad { pub struct ConfigLoad {
@@ -612,6 +838,13 @@ pub fn load_config(path: &str) -> Result<ConfigLoad> {
let filename = p.file_name().unwrap_or(p.as_os_str()); let filename = p.file_name().unwrap_or(p.as_os_str());
v.push(crate::config_dir().join(filename)); v.push(crate::config_dir().join(filename));
v.push(crate::data_dir().join(filename)); v.push(crate::data_dir().join(filename));
// Interactive root and sudo'd users: always consult the XDG path
// so `touch ~/.config/numa/numa.toml` works regardless of whether
// config_dir() routed to FHS (issue #81).
let suggested = crate::suggested_config_path();
if !v.contains(&suggested) {
v.push(suggested);
}
} }
v v
}; };
@@ -632,11 +865,7 @@ pub fn load_config(path: &str) -> Result<ConfigLoad> {
} }
} }
// Show config_dir candidate as the "expected" path — it's actionable let display_path = crate::suggested_config_path().to_string_lossy().to_string();
let display_path = candidates
.get(1)
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|| resolve_path(path));
log::info!("config not found, using defaults (create {})", display_path); log::info!("config not found, using defaults (create {})", display_path);
Ok(ConfigLoad { Ok(ConfigLoad {
config: Config::default(), config: Config::default(),

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Mutex, RwLock}; use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant, SystemTime}; use std::time::{Duration, Instant, SystemTime};
use arc_swap::ArcSwap; use arc_swap::ArcSwap;
@@ -16,7 +16,7 @@ use crate::blocklist::BlocklistStore;
use crate::buffer::BytePacketBuffer; use crate::buffer::BytePacketBuffer;
use crate::cache::{DnsCache, DnssecStatus}; use crate::cache::{DnsCache, DnssecStatus};
use crate::config::{UpstreamMode, ZoneMap}; use crate::config::{UpstreamMode, ZoneMap};
use crate::forward::{forward_query, forward_with_failover, Upstream, UpstreamPool}; use crate::forward::{forward_query_raw, forward_with_failover_raw, Upstream, UpstreamPool};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::health::HealthMeta; use crate::health::HealthMeta;
use crate::lan::PeerStore; use crate::lan::PeerStore;
@@ -27,7 +27,7 @@ use crate::question::QueryType;
use crate::record::DnsRecord; use crate::record::DnsRecord;
use crate::service_store::ServiceStore; use crate::service_store::ServiceStore;
use crate::srtt::SrttCache; use crate::srtt::SrttCache;
use crate::stats::{QueryPath, ServerStats}; use crate::stats::{QueryPath, ServerStats, Transport};
use crate::system_dns::ForwardingRule; use crate::system_dns::ForwardingRule;
pub struct ServerCtx { pub struct ServerCtx {
@@ -35,6 +35,8 @@ pub struct ServerCtx {
pub zone_map: ZoneMap, pub zone_map: ZoneMap,
/// std::sync::RwLock (not tokio) — locks must never be held across .await points. /// std::sync::RwLock (not tokio) — locks must never be held across .await points.
pub cache: RwLock<DnsCache>, pub cache: RwLock<DnsCache>,
/// Domains currently being refreshed in the background (dedup guard).
pub refreshing: Mutex<HashSet<(String, QueryType)>>,
pub stats: Mutex<ServerStats>, pub stats: Mutex<ServerStats>,
pub overrides: RwLock<OverrideStore>, pub overrides: RwLock<OverrideStore>,
pub blocklist: RwLock<BlocklistStore>, pub blocklist: RwLock<BlocklistStore>,
@@ -47,6 +49,7 @@ pub struct ServerCtx {
pub upstream_port: u16, pub upstream_port: u16,
pub lan_ip: Mutex<std::net::Ipv4Addr>, pub lan_ip: Mutex<std::net::Ipv4Addr>,
pub timeout: Duration, pub timeout: Duration,
pub hedge_delay: Duration,
pub proxy_tld: String, pub proxy_tld: String,
pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation
pub lan_enabled: bool, pub lan_enabled: bool,
@@ -81,9 +84,11 @@ pub struct ServerCtx {
/// (and logging parse errors) before calling this function. /// (and logging parse errors) before calling this function.
pub async fn resolve_query( pub async fn resolve_query(
query: DnsPacket, query: DnsPacket,
raw_wire: &[u8],
src_addr: SocketAddr, src_addr: SocketAddr,
ctx: &ServerCtx, ctx: &Arc<ServerCtx>,
) -> crate::Result<BytePacketBuffer> { transport: Transport,
) -> crate::Result<(BytePacketBuffer, QueryPath)> {
let start = Instant::now(); let start = Instant::now();
let (qname, qtype) = match query.questions.first() { let (qname, qtype) = match query.questions.first() {
@@ -91,7 +96,8 @@ pub async fn resolve_query(
None => return Err("empty question section".into()), None => return Err("empty question section".into()),
}; };
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream // Pipeline: overrides -> .localhost -> local zones -> special-use (unless forwarded)
// -> .tld proxy -> blocklist -> cache -> forwarding -> recursive/upstream
// Each lock is scoped to avoid holding MutexGuard across await points. // Each lock is scoped to avoid holding MutexGuard across await points.
let (response, path, dnssec) = { let (response, path, dnssec) = {
let override_record = ctx.overrides.read().unwrap().lookup(&qname); let override_record = ctx.overrides.read().unwrap().lookup(&qname);
@@ -114,8 +120,10 @@ pub async fn resolve_query(
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers = records.clone(); resp.answers = records.clone();
(resp, QueryPath::Local, DnssecStatus::Indeterminate) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if is_special_use_domain(&qname) { } else if is_special_use_domain(&qname)
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally && crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules).is_none()
{
// RFC 6761/8880: answer locally unless a forwarding rule covers this zone.
let resp = special_use_response(&query, &qname, qtype); let resp = special_use_response(&query, &qname, qtype);
(resp, QueryPath::Local, DnssecStatus::Indeterminate) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if !ctx.proxy_tld_suffix.is_empty() } else if !ctx.proxy_tld_suffix.is_empty()
@@ -164,24 +172,31 @@ pub async fn resolve_query(
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate) (resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
} else { } else {
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype); let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
if let Some((cached, cached_dnssec)) = cached { if let Some((cached, cached_dnssec, freshness)) = cached {
if freshness.needs_refresh() {
let key = (qname.clone(), qtype);
let already = !ctx.refreshing.lock().unwrap().insert(key.clone());
if !already {
let ctx = Arc::clone(ctx);
tokio::spawn(async move {
refresh_entry(&ctx, &key.0, key.1).await;
ctx.refreshing.lock().unwrap().remove(&key);
});
}
}
let mut resp = cached; let mut resp = cached;
resp.header.id = query.header.id; resp.header.id = query.header.id;
if cached_dnssec == DnssecStatus::Secure { if cached_dnssec == DnssecStatus::Secure {
resp.header.authed_data = true; resp.header.authed_data = true;
} }
(resp, QueryPath::Cached, cached_dnssec) (resp, QueryPath::Cached, cached_dnssec)
} else if let Some(fwd_addr) = } else if let Some(upstream) =
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules)
{ {
// Conditional forwarding takes priority over recursive mode // Conditional forwarding takes priority over recursive mode
// (e.g. Tailscale .ts.net, VPC private zones) // (e.g. Tailscale .ts.net, VPC private zones)
let upstream = Upstream::Udp(fwd_addr); match forward_and_cache(raw_wire, upstream, ctx, &qname, qtype).await {
match forward_query(&query, &upstream, ctx.timeout).await { Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
Ok(resp) => {
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
}
Err(e) => { Err(e) => {
error!( error!(
"{} | {:?} {} | FORWARD ERROR | {}", "{} | {:?} {} | FORWARD ERROR | {}",
@@ -221,11 +236,26 @@ pub async fn resolve_query(
(resp, path, DnssecStatus::Indeterminate) (resp, path, DnssecStatus::Indeterminate)
} else { } else {
let pool = ctx.upstream_pool.lock().unwrap().clone(); let pool = ctx.upstream_pool.lock().unwrap().clone();
match forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await { match forward_with_failover_raw(
Ok(resp) => { raw_wire,
ctx.cache.write().unwrap().insert(&qname, qtype, &resp); &pool,
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) &ctx.srtt,
} ctx.timeout,
ctx.hedge_delay,
)
.await
{
Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) {
Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
Err(e) => {
error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e);
(
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
DnssecStatus::Indeterminate,
)
}
},
Err(e) => { Err(e) => {
error!( error!(
"{} | {:?} {} | UPSTREAM ERROR | {}", "{} | {:?} {} | UPSTREAM ERROR | {}",
@@ -327,7 +357,7 @@ pub async fn resolve_query(
// Record stats and query log // Record stats and query log
{ {
let mut s = ctx.stats.lock().unwrap(); let mut s = ctx.stats.lock().unwrap();
let total = s.record(path); let total = s.record(path, transport);
if total.is_multiple_of(1000) { if total.is_multiple_of(1000) {
s.log_summary(); s.log_summary();
} }
@@ -339,19 +369,87 @@ pub async fn resolve_query(
domain: qname, domain: qname,
query_type: qtype, query_type: qtype,
path, path,
transport,
rescode: response.header.rescode, rescode: response.header.rescode,
latency_us: elapsed.as_micros() as u64, latency_us: elapsed.as_micros() as u64,
dnssec, dnssec,
}); });
Ok(resp_buffer) Ok((resp_buffer, path))
}
fn cache_and_parse(
ctx: &ServerCtx,
qname: &str,
qtype: QueryType,
resp_wire: &[u8],
) -> crate::Result<DnsPacket> {
ctx.cache
.write()
.unwrap()
.insert_wire(qname, qtype, resp_wire, DnssecStatus::Indeterminate);
let mut buf = BytePacketBuffer::from_bytes(resp_wire);
DnsPacket::from_buffer(&mut buf)
}
/// Re-resolve a single (domain, qtype) and update the cache.
/// Used for both stale-entry refresh and proactive cache warming.
pub async fn refresh_entry(ctx: &ServerCtx, qname: &str, qtype: QueryType) {
let query = DnsPacket::query(0, qname, qtype);
if ctx.upstream_mode == UpstreamMode::Recursive {
if let Ok(resp) = crate::recursive::resolve_recursive(
qname,
qtype,
&ctx.cache,
&query,
&ctx.root_hints,
&ctx.srtt,
)
.await
{
ctx.cache.write().unwrap().insert(qname, qtype, &resp);
}
} else {
let mut buf = BytePacketBuffer::new();
if query.write(&mut buf).is_ok() {
let pool = ctx.upstream_pool.lock().unwrap().clone();
if let Ok(wire) = forward_with_failover_raw(
buf.filled(),
&pool,
&ctx.srtt,
ctx.timeout,
ctx.hedge_delay,
)
.await
{
ctx.cache.write().unwrap().insert_wire(
qname,
qtype,
&wire,
DnssecStatus::Indeterminate,
);
}
}
}
}
async fn forward_and_cache(
wire: &[u8],
upstream: &Upstream,
ctx: &ServerCtx,
qname: &str,
qtype: QueryType,
) -> crate::Result<DnsPacket> {
let resp_wire = forward_query_raw(wire, upstream, ctx.timeout).await?;
cache_and_parse(ctx, qname, qtype, &resp_wire)
} }
/// Handle a DNS query received over UDP. Thin wrapper around resolve_query.
pub async fn handle_query( pub async fn handle_query(
mut buffer: BytePacketBuffer, mut buffer: BytePacketBuffer,
raw_len: usize,
src_addr: SocketAddr, src_addr: SocketAddr,
ctx: &ServerCtx, ctx: &Arc<ServerCtx>,
transport: Transport,
) -> crate::Result<()> { ) -> crate::Result<()> {
let query = match DnsPacket::from_buffer(&mut buffer) { let query = match DnsPacket::from_buffer(&mut buffer) {
Ok(packet) => packet, Ok(packet) => packet,
@@ -360,8 +458,8 @@ pub async fn handle_query(
return Ok(()); return Ok(());
} }
}; };
match resolve_query(query, src_addr, ctx).await { match resolve_query(query, &buffer.buf[..raw_len], src_addr, ctx, transport).await {
Ok(resp_buffer) => { Ok((resp_buffer, _)) => {
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
} }
Err(e) => { Err(e) => {
@@ -940,4 +1038,219 @@ mod tests {
"error message must be preserved for logging" "error message must be preserved for logging"
); );
} }
// ---- Full-pipeline resolve_query tests ----
/// Send a query through the full resolve_query pipeline and return
/// the parsed response + query path.
async fn resolve_in_test(
ctx: &Arc<ServerCtx>,
domain: &str,
qtype: QueryType,
) -> (DnsPacket, QueryPath) {
let query = DnsPacket::query(0xBEEF, domain, qtype);
let mut buf = BytePacketBuffer::new();
query.write(&mut buf).unwrap();
let raw = &buf.buf[..buf.pos];
let src: SocketAddr = "127.0.0.1:1234".parse().unwrap();
let (resp_buf, path) = resolve_query(query, raw, src, ctx, Transport::Udp)
.await
.unwrap();
let mut resp_parse_buf = BytePacketBuffer::from_bytes(resp_buf.filled());
let resp = DnsPacket::from_buffer(&mut resp_parse_buf).unwrap();
(resp, path)
}
#[tokio::test]
async fn special_use_private_ptr_returns_nxdomain() {
let ctx = Arc::new(crate::testutil::test_ctx().await);
let (resp, path) =
resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await;
assert_eq!(path, QueryPath::Local);
assert_eq!(resp.header.rescode, ResultCode::NXDOMAIN);
}
#[tokio::test]
async fn forwarding_rule_overrides_special_use_domain() {
let mut resp = DnsPacket::new();
resp.header.response = true;
resp.header.rescode = ResultCode::NOERROR;
let upstream_addr = crate::testutil::mock_upstream(resp).await;
let mut ctx = crate::testutil::test_ctx().await;
ctx.forwarding_rules = vec![ForwardingRule::new(
"168.192.in-addr.arpa".to_string(),
Upstream::Udp(upstream_addr),
)];
let ctx = Arc::new(ctx);
let (resp, path) =
resolve_in_test(&ctx, "153.188.168.192.in-addr.arpa", QueryType::PTR).await;
assert_eq!(
path,
QueryPath::Forwarded,
"forwarding rule must take precedence over special-use NXDOMAIN"
);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
}
#[tokio::test]
async fn pipeline_override_takes_precedence() {
let ctx = crate::testutil::test_ctx().await;
ctx.overrides
.write()
.unwrap()
.insert("override.test", "1.2.3.4", 60, None)
.unwrap();
let ctx = Arc::new(ctx);
let (resp, path) = resolve_in_test(&ctx, "override.test", QueryType::A).await;
assert_eq!(path, QueryPath::Overridden);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
assert_eq!(resp.answers.len(), 1);
}
#[tokio::test]
async fn pipeline_localhost_resolves_to_loopback() {
let ctx = Arc::new(crate::testutil::test_ctx().await);
let (resp, path) = resolve_in_test(&ctx, "localhost", QueryType::A).await;
assert_eq!(path, QueryPath::Local);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
match &resp.answers[0] {
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST),
other => panic!("expected A record, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_localhost_subdomain_resolves_to_loopback() {
let ctx = Arc::new(crate::testutil::test_ctx().await);
let (resp, path) = resolve_in_test(&ctx, "app.localhost", QueryType::A).await;
assert_eq!(path, QueryPath::Local);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
match &resp.answers[0] {
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST),
other => panic!("expected A record, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_local_zone_returns_configured_record() {
let mut ctx = crate::testutil::test_ctx().await;
let mut inner = HashMap::new();
inner.insert(
QueryType::A,
vec![DnsRecord::A {
domain: "myapp.test".to_string(),
addr: Ipv4Addr::new(10, 0, 0, 42),
ttl: 300,
}],
);
ctx.zone_map.insert("myapp.test".to_string(), inner);
let ctx = Arc::new(ctx);
let (resp, path) = resolve_in_test(&ctx, "myapp.test", QueryType::A).await;
assert_eq!(path, QueryPath::Local);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
match &resp.answers[0] {
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)),
other => panic!("expected A record, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_tld_proxy_resolves_service() {
let ctx = crate::testutil::test_ctx().await;
ctx.services.lock().unwrap().insert("grafana", 3000);
let ctx = Arc::new(ctx);
let (resp, path) = resolve_in_test(&ctx, "grafana.numa", QueryType::A).await;
assert_eq!(path, QueryPath::Local);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
match &resp.answers[0] {
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::LOCALHOST),
other => panic!("expected A record, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_blocklist_sinkhole() {
let ctx = crate::testutil::test_ctx().await;
let mut domains = std::collections::HashSet::new();
domains.insert("ads.tracker.test".to_string());
ctx.blocklist.write().unwrap().swap_domains(domains, vec![]);
let ctx = Arc::new(ctx);
let (resp, path) = resolve_in_test(&ctx, "ads.tracker.test", QueryType::A).await;
assert_eq!(path, QueryPath::Blocked);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
match &resp.answers[0] {
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::UNSPECIFIED),
other => panic!("expected sinkhole A record, got {:?}", other),
}
}
#[tokio::test]
async fn pipeline_cache_hit() {
let ctx = Arc::new(crate::testutil::test_ctx().await);
// Pre-populate cache with a response
let mut pkt = DnsPacket::new();
pkt.header.response = true;
pkt.header.rescode = ResultCode::NOERROR;
pkt.questions.push(crate::question::DnsQuestion {
name: "cached.test".to_string(),
qtype: QueryType::A,
});
pkt.answers.push(DnsRecord::A {
domain: "cached.test".to_string(),
addr: Ipv4Addr::new(5, 5, 5, 5),
ttl: 3600,
});
ctx.cache
.write()
.unwrap()
.insert("cached.test", QueryType::A, &pkt);
let (resp, path) = resolve_in_test(&ctx, "cached.test", QueryType::A).await;
assert_eq!(path, QueryPath::Cached);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
}
#[tokio::test]
async fn pipeline_forwarding_returns_upstream_answer() {
let mut upstream_resp = DnsPacket::new();
upstream_resp.header.response = true;
upstream_resp.header.rescode = ResultCode::NOERROR;
upstream_resp.answers.push(DnsRecord::A {
domain: "internal.corp".to_string(),
addr: Ipv4Addr::new(10, 1, 2, 3),
ttl: 600,
});
let upstream_addr = crate::testutil::mock_upstream(upstream_resp).await;
let mut ctx = crate::testutil::test_ctx().await;
ctx.forwarding_rules = vec![ForwardingRule::new(
"corp".to_string(),
Upstream::Udp(upstream_addr),
)];
let ctx = Arc::new(ctx);
let (resp, path) = resolve_in_test(&ctx, "internal.corp", QueryType::A).await;
assert_eq!(path, QueryPath::Forwarded);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
assert_eq!(resp.answers.len(), 1);
match &resp.answers[0] {
DnsRecord::A { domain, addr, .. } => {
assert_eq!(domain, "internal.corp");
assert_eq!(*addr, Ipv4Addr::new(10, 1, 2, 3));
}
other => panic!("expected A record, got {:?}", other),
}
}
} }

View File

@@ -10,6 +10,7 @@ use crate::buffer::BytePacketBuffer;
use crate::ctx::{resolve_query, ServerCtx}; use crate::ctx::{resolve_query, ServerCtx};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::stats::Transport;
const MAX_DNS_MSG: usize = 4096; const MAX_DNS_MSG: usize = 4096;
const DOH_CONTENT_TYPE: &str = "application/dns-message"; const DOH_CONTENT_TYPE: &str = "application/dns-message";
@@ -48,19 +49,48 @@ pub async fn doh_post(State(state): State<super::proxy::DohState>, req: Request)
} }
fn is_doh_host(host: Option<&str>, tld: &str) -> bool { fn is_doh_host(host: Option<&str>, tld: &str) -> bool {
match host { let h = match host {
Some(h) if h == tld => true, Some(h) => h,
Some(h) => { None => return false,
h.len() == 2 * tld.len() + 1 };
&& h.starts_with(tld) let base = strip_port(h).unwrap_or(h);
&& h.as_bytes().get(tld.len()) == Some(&b'.') is_loopback_host(base) || is_tld_match(base, tld)
&& h.ends_with(tld) }
fn strip_port(h: &str) -> Option<&str> {
if h.starts_with('[') {
// [::1]:443 → [::1]
let (base, port) = h.rsplit_once("]:")?;
port.bytes()
.all(|b| b.is_ascii_digit())
.then(|| &h[..base.len() + 1])
} else {
let (base, port) = h.rsplit_once(':')?;
// Bare IPv6 like "::1" has multiple colons — not a port suffix
if base.contains(':') {
return None;
} }
None => false, port.bytes().all(|b| b.is_ascii_digit()).then_some(base)
} }
} }
async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Response { fn is_loopback_host(h: &str) -> bool {
matches!(h, "127.0.0.1" | "::1" | "[::1]" | "localhost")
}
fn is_tld_match(h: &str, tld: &str) -> bool {
h == tld
|| (h.len() == 2 * tld.len() + 1
&& h.starts_with(tld)
&& h.as_bytes().get(tld.len()) == Some(&b'.')
&& h.ends_with(tld))
}
async fn resolve_doh(
dns_bytes: &[u8],
src: SocketAddr,
ctx: &std::sync::Arc<ServerCtx>,
) -> Response {
let mut buffer = BytePacketBuffer::from_bytes(dns_bytes); let mut buffer = BytePacketBuffer::from_bytes(dns_bytes);
let query = match DnsPacket::from_buffer(&mut buffer) { let query = match DnsPacket::from_buffer(&mut buffer) {
Ok(q) => q, Ok(q) => q,
@@ -82,8 +112,8 @@ async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Resp
let query_rd = query.header.recursion_desired; let query_rd = query.header.recursion_desired;
let questions = query.questions.clone(); let questions = query.questions.clone();
match resolve_query(query, src, ctx).await { match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await {
Ok(resp_buffer) => { Ok((resp_buffer, _)) => {
let min_ttl = extract_min_ttl(resp_buffer.filled()); let min_ttl = extract_min_ttl(resp_buffer.filled());
dns_response(resp_buffer.filled(), min_ttl) dns_response(resp_buffer.filled(), min_ttl)
} }
@@ -102,11 +132,10 @@ async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Resp
} }
fn extract_min_ttl(wire: &[u8]) -> u32 { fn extract_min_ttl(wire: &[u8]) -> u32 {
let mut buf = BytePacketBuffer::from_bytes(wire); crate::wire::scan_ttl_offsets(wire)
match DnsPacket::from_buffer(&mut buf) { .ok()
Ok(pkt) => pkt.answers.iter().map(|r| r.ttl()).min().unwrap_or(0), .and_then(|meta| crate::wire::min_ttl_from_wire(wire, &meta))
Err(_) => 0, .unwrap_or(0)
}
} }
fn dns_response(wire: &[u8], min_ttl: u32) -> Response { fn dns_response(wire: &[u8], min_ttl: u32) -> Response {
@@ -144,6 +173,13 @@ mod tests {
fn is_doh_host_matches_tld() { fn is_doh_host_matches_tld() {
assert!(is_doh_host(Some("numa"), "numa")); assert!(is_doh_host(Some("numa"), "numa"));
assert!(is_doh_host(Some("numa.numa"), "numa")); assert!(is_doh_host(Some("numa.numa"), "numa"));
assert!(is_doh_host(Some("127.0.0.1"), "numa"));
assert!(is_doh_host(Some("127.0.0.1:443"), "numa"));
assert!(is_doh_host(Some("::1"), "numa"));
assert!(is_doh_host(Some("[::1]"), "numa"));
assert!(is_doh_host(Some("[::1]:443"), "numa"));
assert!(is_doh_host(Some("localhost"), "numa"));
assert!(is_doh_host(Some("localhost:443"), "numa"));
assert!(!is_doh_host(Some("foo.numa"), "numa")); assert!(!is_doh_host(Some("foo.numa"), "numa"));
assert!(!is_doh_host(None, "numa")); assert!(!is_doh_host(None, "numa"));
} }

View File

@@ -15,6 +15,7 @@ use crate::config::DotConfig;
use crate::ctx::{resolve_query, ServerCtx}; use crate::ctx::{resolve_query, ServerCtx};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::stats::Transport;
const MAX_CONNECTIONS: usize = 512; const MAX_CONNECTIONS: usize = 512;
const IDLE_TIMEOUT: Duration = Duration::from_secs(30); const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
@@ -153,8 +154,11 @@ async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc<Serv
/// Handle a single persistent DoT connection (RFC 7858). /// Handle a single persistent DoT connection (RFC 7858).
/// Reads length-prefixed DNS queries until EOF, idle timeout, or error. /// Reads length-prefixed DNS queries until EOF, idle timeout, or error.
async fn handle_dot_connection<S>(mut stream: S, remote_addr: SocketAddr, ctx: &ServerCtx) async fn handle_dot_connection<S>(
where mut stream: S,
remote_addr: SocketAddr,
ctx: &std::sync::Arc<ServerCtx>,
) where
S: AsyncReadExt + AsyncWriteExt + Unpin, S: AsyncReadExt + AsyncWriteExt + Unpin,
{ {
loop { loop {
@@ -177,8 +181,6 @@ where
break; break;
}; };
// Parse query up-front so we can echo its question section in SERVFAIL
// responses when resolve_query fails.
let query = match DnsPacket::from_buffer(&mut buffer) { let query = match DnsPacket::from_buffer(&mut buffer) {
Ok(q) => q, Ok(q) => q,
Err(e) => { Err(e) => {
@@ -200,8 +202,16 @@ where
} }
}; };
match resolve_query(query.clone(), remote_addr, ctx).await { match resolve_query(
Ok(resp_buffer) => { query.clone(),
&buffer.buf[..msg_len],
remote_addr,
ctx,
Transport::Dot,
)
.await
{
Ok((resp_buffer, _)) => {
if write_framed(&mut stream, resp_buffer.filled()) if write_framed(&mut stream, resp_buffer.filled())
.await .await
.is_err() .is_err()
@@ -269,7 +279,7 @@ where
mod tests { mod tests {
use super::*; use super::*;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Mutex, RwLock}; use std::sync::Mutex;
use rcgen::{CertificateParams, DnType, KeyPair}; use rcgen::{CertificateParams, DnType, KeyPair};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
@@ -334,61 +344,29 @@ mod tests {
async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) { async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) {
let (server_tls, cert_der) = test_tls_configs(); let (server_tls, cert_der) = test_tls_configs();
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap(); let upstream_addr = crate::testutil::blackhole_upstream();
// Bind an unresponsive upstream and leak it so it lives for the test duration.
let blackhole = Box::leak(Box::new(std::net::UdpSocket::bind("127.0.0.1:0").unwrap())); let mut ctx = crate::testutil::test_ctx().await;
let upstream_addr = blackhole.local_addr().unwrap(); ctx.zone_map = {
let ctx = Arc::new(ServerCtx { let mut m = HashMap::new();
socket, let mut inner = HashMap::new();
zone_map: { inner.insert(
let mut m = HashMap::new(); QueryType::A,
let mut inner = HashMap::new(); vec![DnsRecord::A {
inner.insert( domain: "dot-test.example".to_string(),
QueryType::A, addr: std::net::Ipv4Addr::new(10, 0, 0, 1),
vec![DnsRecord::A { ttl: 300,
domain: "dot-test.example".to_string(), }],
addr: std::net::Ipv4Addr::new(10, 0, 0, 1), );
ttl: 300, m.insert("dot-test.example".to_string(), inner);
}], m
); };
m.insert("dot-test.example".to_string(), inner); ctx.upstream_pool = Mutex::new(crate::forward::UpstreamPool::new(
m vec![crate::forward::Upstream::Udp(upstream_addr)],
}, vec![],
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), ));
stats: Mutex::new(crate::stats::ServerStats::new()), ctx.tls_config = Some(arc_swap::ArcSwap::from(server_tls));
overrides: RwLock::new(crate::override_store::OverrideStore::new()), let ctx = Arc::new(ctx);
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
services: Mutex::new(crate::service_store::ServiceStore::new()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
forwarding_rules: Vec::new(),
upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
vec![crate::forward::Upstream::Udp(upstream_addr)],
vec![],
)),
upstream_auto: false,
upstream_port: 53,
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
timeout: Duration::from_millis(200),
proxy_tld: "numa".to_string(),
proxy_tld_suffix: ".numa".to_string(),
lan_enabled: false,
config_path: String::new(),
config_found: false,
config_dir: std::path::PathBuf::from("/tmp"),
data_dir: std::path::PathBuf::from("/tmp"),
tls_config: Some(arc_swap::ArcSwap::from(server_tls)),
upstream_mode: crate::config::UpstreamMode::Forward,
root_hints: Vec::new(),
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
inflight: Mutex::new(HashMap::new()),
dnssec_enabled: false,
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
mobile_enabled: false,
mobile_port: 8765,
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap(); let addr = listener.local_addr().unwrap();

View File

@@ -18,6 +18,11 @@ pub enum Upstream {
url: String, url: String,
client: reqwest::Client, client: reqwest::Client,
}, },
Dot {
addr: SocketAddr,
tls_name: Option<String>,
connector: tokio_rustls::TlsConnector,
},
} }
impl PartialEq for Upstream { impl PartialEq for Upstream {
@@ -25,21 +30,35 @@ impl PartialEq for Upstream {
match (self, other) { match (self, other) {
(Self::Udp(a), Self::Udp(b)) => a == b, (Self::Udp(a), Self::Udp(b)) => a == b,
(Self::Doh { url: a, .. }, Self::Doh { url: b, .. }) => a == b, (Self::Doh { url: a, .. }, Self::Doh { url: b, .. }) => a == b,
(Self::Dot { addr: a, .. }, Self::Dot { addr: b, .. }) => a == b,
_ => false, _ => false,
} }
} }
} }
impl fmt::Debug for Upstream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(self, f)
}
}
impl fmt::Display for Upstream { impl fmt::Display for Upstream {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Upstream::Udp(addr) => write!(f, "{}", addr), Upstream::Udp(addr) => write!(f, "{}", addr),
Upstream::Doh { url, .. } => f.write_str(url), Upstream::Doh { url, .. } => f.write_str(url),
Upstream::Dot { addr, tls_name, .. } => match tls_name {
Some(name) => write!(f, "tls://{}#{}", addr, name),
None => write!(f, "tls://{}", addr),
},
} }
} }
} }
pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result<SocketAddr, String> { pub(crate) fn parse_upstream_addr(
s: &str,
default_port: u16,
) -> std::result::Result<SocketAddr, String> {
// Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353" // Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353"
if let Ok(addr) = s.parse::<SocketAddr>() { if let Ok(addr) = s.parse::<SocketAddr>() {
return Ok(addr); return Ok(addr);
@@ -55,6 +74,13 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
if s.starts_with("https://") { if s.starts_with("https://") {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.use_rustls_tls() .use_rustls_tls()
.http2_initial_stream_window_size(65_535)
.http2_initial_connection_window_size(65_535)
.http2_keep_alive_interval(Duration::from_secs(15))
.http2_keep_alive_while_idle(true)
.http2_keep_alive_timeout(Duration::from_secs(10))
.pool_idle_timeout(Duration::from_secs(300))
.pool_max_idle_per_host(1)
.build() .build()
.unwrap_or_default(); .unwrap_or_default();
return Ok(Upstream::Doh { return Ok(Upstream::Doh {
@@ -62,10 +88,36 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
client, client,
}); });
} }
// tls://IP:PORT#hostname or tls://IP#hostname (default port 853)
if let Some(rest) = s.strip_prefix("tls://") {
let (addr_part, tls_name) = match rest.find('#') {
Some(i) => (&rest[..i], Some(rest[i + 1..].to_string())),
None => (rest, None),
};
let addr = parse_upstream_addr(addr_part, 853)?;
let connector = build_dot_connector()?;
return Ok(Upstream::Dot {
addr,
tls_name,
connector,
});
}
let addr = parse_upstream_addr(s, default_port)?; let addr = parse_upstream_addr(s, default_port)?;
Ok(Upstream::Udp(addr)) Ok(Upstream::Udp(addr))
} }
fn build_dot_connector() -> Result<tokio_rustls::TlsConnector> {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut root_store = rustls::RootCertStore::empty();
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
Ok(tokio_rustls::TlsConnector::from(std::sync::Arc::new(
config,
)))
}
#[derive(Clone)] #[derive(Clone)]
pub struct UpstreamPool { pub struct UpstreamPool {
primary: Vec<Upstream>, primary: Vec<Upstream>,
@@ -114,67 +166,16 @@ impl UpstreamPool {
} }
} }
pub async fn forward_with_failover(
query: &DnsPacket,
pool: &UpstreamPool,
srtt: &RwLock<SrttCache>,
timeout_duration: Duration,
) -> Result<DnsPacket> {
// Build candidate list: primary (sorted by SRTT for UDP) then fallback
let mut candidates: Vec<(usize, u64)> = pool
.primary
.iter()
.enumerate()
.map(|(i, u)| {
let rtt = match u {
Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()),
_ => 0, // DoH: keep config order (stable sort preserves it)
};
(i, rtt)
})
.collect();
candidates.sort_by_key(|&(_, rtt)| rtt);
let all_upstreams: Vec<&Upstream> = candidates
.iter()
.map(|&(i, _)| &pool.primary[i])
.chain(pool.fallback.iter())
.collect();
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
for upstream in &all_upstreams {
let start = Instant::now();
match forward_query(query, upstream, timeout_duration).await {
Ok(resp) => {
if let Upstream::Udp(addr) = upstream {
let rtt_ms = start.elapsed().as_millis() as u64;
srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false);
}
return Ok(resp);
}
Err(e) => {
if let Upstream::Udp(addr) = upstream {
srtt.write().unwrap().record_failure(addr.ip());
}
log::debug!("upstream {} failed: {}", upstream, e);
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| "no upstream configured".into()))
}
pub async fn forward_query( pub async fn forward_query(
query: &DnsPacket, query: &DnsPacket,
upstream: &Upstream, upstream: &Upstream,
timeout_duration: Duration, timeout_duration: Duration,
) -> Result<DnsPacket> { ) -> Result<DnsPacket> {
match upstream { let mut send_buffer = BytePacketBuffer::new();
Upstream::Udp(addr) => forward_udp(query, *addr, timeout_duration).await, query.write(&mut send_buffer)?;
Upstream::Doh { url, client } => forward_doh(query, url, client, timeout_duration).await, let data = forward_query_raw(send_buffer.filled(), upstream, timeout_duration).await?;
} let mut recv_buffer = BytePacketBuffer::from_bytes(&data);
DnsPacket::from_buffer(&mut recv_buffer)
} }
pub(crate) async fn forward_udp( pub(crate) async fn forward_udp(
@@ -182,24 +183,10 @@ pub(crate) async fn forward_udp(
upstream: SocketAddr, upstream: SocketAddr,
timeout_duration: Duration, timeout_duration: Duration,
) -> Result<DnsPacket> { ) -> Result<DnsPacket> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
let mut send_buffer = BytePacketBuffer::new(); let mut send_buffer = BytePacketBuffer::new();
query.write(&mut send_buffer)?; query.write(&mut send_buffer)?;
let data = forward_udp_raw(send_buffer.filled(), upstream, timeout_duration).await?;
socket.send_to(send_buffer.filled(), upstream).await?; let mut recv_buffer = BytePacketBuffer::from_bytes(&data);
let mut recv_buffer = BytePacketBuffer::new();
let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buffer.buf)).await??;
if size == recv_buffer.buf.len() {
log::debug!(
"upstream response truncated ({} bytes, buffer {})",
size,
recv_buffer.buf.len()
);
}
DnsPacket::from_buffer(&mut recv_buffer) DnsPacket::from_buffer(&mut recv_buffer)
} }
@@ -236,22 +223,209 @@ pub(crate) async fn forward_tcp(
DnsPacket::from_buffer(&mut recv_buffer) DnsPacket::from_buffer(&mut recv_buffer)
} }
async fn forward_doh( async fn forward_dot_raw(
query: &DnsPacket, wire: &[u8],
addr: SocketAddr,
tls_name: &Option<String>,
connector: &tokio_rustls::TlsConnector,
timeout_duration: Duration,
) -> Result<Vec<u8>> {
use rustls::pki_types::ServerName;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
let server_name = match tls_name {
Some(name) => ServerName::try_from(name.clone())?,
None => ServerName::try_from(addr.ip().to_string())?,
};
let tcp = timeout(timeout_duration, TcpStream::connect(addr)).await??;
let mut tls = timeout(timeout_duration, connector.connect(server_name, tcp)).await??;
let mut outbuf = Vec::with_capacity(2 + wire.len());
outbuf.extend_from_slice(&(wire.len() as u16).to_be_bytes());
outbuf.extend_from_slice(wire);
timeout(timeout_duration, tls.write_all(&outbuf)).await??;
let mut len_buf = [0u8; 2];
timeout(timeout_duration, tls.read_exact(&mut len_buf)).await??;
let resp_len = u16::from_be_bytes(len_buf) as usize;
let mut data = vec![0u8; resp_len];
timeout(timeout_duration, tls.read_exact(&mut data)).await??;
Ok(data)
}
pub async fn forward_query_raw(
wire: &[u8],
upstream: &Upstream,
timeout_duration: Duration,
) -> Result<Vec<u8>> {
match upstream {
Upstream::Udp(addr) => forward_udp_raw(wire, *addr, timeout_duration).await,
Upstream::Doh { url, client } => forward_doh_raw(wire, url, client, timeout_duration).await,
Upstream::Dot {
addr,
tls_name,
connector,
} => forward_dot_raw(wire, *addr, tls_name, connector, timeout_duration).await,
}
}
pub async fn forward_with_hedging_raw(
wire: &[u8],
primary: &Upstream,
secondary: &Upstream,
hedge_delay: Duration,
timeout_duration: Duration,
) -> Result<Vec<u8>> {
use tokio::time::sleep;
let primary_fut = forward_query_raw(wire, primary, timeout_duration);
tokio::pin!(primary_fut);
let delay = sleep(hedge_delay);
tokio::pin!(delay);
// Phase 1: wait for either primary to return, or the hedge delay.
tokio::select! {
result = &mut primary_fut => return result,
_ = &mut delay => {}
}
// Phase 2: hedge delay expired — fire secondary while still polling primary.
let secondary_fut = forward_query_raw(wire, secondary, timeout_duration);
tokio::pin!(secondary_fut);
// First successful response wins. If one errors, wait for the other.
let mut primary_err: Option<crate::Error> = None;
let mut secondary_err: Option<crate::Error> = None;
loop {
tokio::select! {
r = &mut primary_fut, if primary_err.is_none() => {
match r {
Ok(resp) => return Ok(resp),
Err(e) => {
if let Some(se) = secondary_err.take() {
return Err(se);
}
primary_err = Some(e);
}
}
}
r = &mut secondary_fut, if secondary_err.is_none() => {
match r {
Ok(resp) => return Ok(resp),
Err(e) => {
if let Some(pe) = primary_err.take() {
return Err(pe);
}
secondary_err = Some(e);
}
}
}
}
match (primary_err, secondary_err) {
(Some(pe), Some(_)) => return Err(pe),
(pe, se) => {
primary_err = pe;
secondary_err = se;
}
}
}
}
pub async fn forward_with_failover_raw(
wire: &[u8],
pool: &UpstreamPool,
srtt: &RwLock<SrttCache>,
timeout_duration: Duration,
hedge_delay: Duration,
) -> Result<Vec<u8>> {
let mut candidates: Vec<(usize, u64)> = pool
.primary
.iter()
.enumerate()
.map(|(i, u)| {
let rtt = match u {
Upstream::Udp(addr) => srtt.read().unwrap().get(addr.ip()),
_ => 0,
};
(i, rtt)
})
.collect();
candidates.sort_by_key(|&(_, rtt)| rtt);
let all_upstreams: Vec<&Upstream> = candidates
.iter()
.map(|&(i, _)| &pool.primary[i])
.chain(pool.fallback.iter())
.collect();
let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
for upstream in &all_upstreams {
let start = Instant::now();
let result = if !hedge_delay.is_zero() {
// Hedge against the same upstream: independent h2 streams (DoH),
// independent UDP packets (plain DNS), or independent TLS
// connections (DoT). Rescues packet loss, dispatch spikes, and
// TLS handshake stalls.
forward_with_hedging_raw(wire, upstream, upstream, hedge_delay, timeout_duration).await
} else {
forward_query_raw(wire, upstream, timeout_duration).await
};
match result {
Ok(resp) => {
if let Upstream::Udp(addr) = upstream {
let rtt_ms = start.elapsed().as_millis() as u64;
srtt.write().unwrap().record_rtt(addr.ip(), rtt_ms, false);
}
return Ok(resp);
}
Err(e) => {
if let Upstream::Udp(addr) = upstream {
srtt.write().unwrap().record_failure(addr.ip());
}
log::debug!("upstream {} failed: {}", upstream, e);
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| "no upstream configured".into()))
}
async fn forward_udp_raw(
wire: &[u8],
upstream: SocketAddr,
timeout_duration: Duration,
) -> Result<Vec<u8>> {
let socket = UdpSocket::bind("0.0.0.0:0").await?;
socket.send_to(wire, upstream).await?;
let mut recv_buf = vec![0u8; 4096];
let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buf)).await??;
recv_buf.truncate(size);
Ok(recv_buf)
}
async fn forward_doh_raw(
wire: &[u8],
url: &str, url: &str,
client: &reqwest::Client, client: &reqwest::Client,
timeout_duration: Duration, timeout_duration: Duration,
) -> Result<DnsPacket> { ) -> Result<Vec<u8>> {
let mut send_buffer = BytePacketBuffer::new();
query.write(&mut send_buffer)?;
let resp = timeout( let resp = timeout(
timeout_duration, timeout_duration,
client client
.post(url) .post(url)
.header("content-type", "application/dns-message") .header("content-type", "application/dns-message")
.header("accept", "application/dns-message") .header("accept", "application/dns-message")
.body(send_buffer.filled().to_vec()) .body(wire.to_vec())
.send(), .send(),
) )
.await?? .await??
@@ -259,9 +433,25 @@ async fn forward_doh(
let bytes = resp.bytes().await?; let bytes = resp.bytes().await?;
log::debug!("DoH response: {} bytes", bytes.len()); log::debug!("DoH response: {} bytes", bytes.len());
Ok(bytes.to_vec())
}
let mut recv_buffer = BytePacketBuffer::from_bytes(&bytes); /// Send a lightweight keepalive query to a DoH upstream to prevent
DnsPacket::from_buffer(&mut recv_buffer) /// the HTTP/2 + TLS connection from going idle and being torn down.
pub async fn keepalive_doh(upstream: &Upstream) {
if let Upstream::Doh { url, client } = upstream {
// Query for . NS — minimal, always succeeds, response is small
let wire: &[u8] = &[
0x00, 0x00, // ID
0x01, 0x00, // flags: RD=1
0x00, 0x01, // QDCOUNT=1
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // AN=0, NS=0, AR=0
0x00, // root name (.)
0x00, 0x02, // type NS
0x00, 0x01, // class IN
];
let _ = forward_doh_raw(wire, url, client, Duration::from_secs(5)).await;
}
} }
#[cfg(test)] #[cfg(test)]
@@ -476,10 +666,19 @@ mod tests {
); );
let srtt = RwLock::new(SrttCache::new(true)); let srtt = RwLock::new(SrttCache::new(true));
let result = forward_with_failover(&query, &pool, &srtt, Duration::from_millis(500)) let wire = to_wire(&query);
.await let resp_wire = forward_with_failover_raw(
.expect("should fail over to second upstream"); &wire,
&pool,
&srtt,
Duration::from_millis(500),
Duration::ZERO,
)
.await
.expect("should fail over to second upstream");
let mut buf = BytePacketBuffer::from_bytes(&resp_wire);
let result = DnsPacket::from_buffer(&mut buf).unwrap();
assert_eq!(result.header.id, 0xABCD); assert_eq!(result.header.id, 0xABCD);
assert_eq!(result.answers.len(), 1); assert_eq!(result.answers.len(), 1);
} }

View File

@@ -26,6 +26,10 @@ pub mod srtt;
pub mod stats; pub mod stats;
pub mod system_dns; pub mod system_dns;
pub mod tls; pub mod tls;
pub mod wire;
#[cfg(test)]
pub(crate) mod testutil;
pub type Error = Box<dyn std::error::Error + Send + Sync>; pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;
@@ -44,6 +48,42 @@ pub fn hostname() -> String {
.unwrap_or_else(|| "numa".to_string()) .unwrap_or_else(|| "numa".to_string())
} }
/// Path to suggest to an interactive user when asking them to create
/// `numa.toml`. Prefers `$HOME/.config/numa/numa.toml` when HOME is set
/// (actionable without sudo); falls back to `config_dir()` otherwise.
///
/// Note: `config_dir()` routes interactive root to FHS (`/var/lib/numa`)
/// so that runtime state like `services.json` stays continuous with the
/// installed daemon. This helper exists specifically to give advisories
/// and `load_config` an XDG-aware path for user-authored config, without
/// moving runtime state out of FHS — see issue #81.
pub(crate) fn suggested_config_path() -> std::path::PathBuf {
#[cfg(not(windows))]
{
resolve_suggested_config_path(std::env::var("HOME").ok().as_deref(), config_dir)
}
#[cfg(windows)]
{
config_dir().join("numa.toml")
}
}
#[cfg(not(windows))]
fn resolve_suggested_config_path<F>(home: Option<&str>, fallback_dir: F) -> std::path::PathBuf
where
F: FnOnce() -> std::path::PathBuf,
{
if let Some(home) = home {
if !home.is_empty() && home != "/" {
return std::path::PathBuf::from(home)
.join(".config")
.join("numa")
.join("numa.toml");
}
}
fallback_dir().join("numa.toml")
}
/// Shared config directory for persistent data (services.json, etc). /// Shared config directory for persistent data (services.json, etc).
/// Unix users: ~/.config/numa/ /// Unix users: ~/.config/numa/
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa /// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
@@ -163,4 +203,73 @@ mod tests {
fn linux_data_dir_only_fhs_uses_fhs() { fn linux_data_dir_only_fhs_uses_fhs() {
assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa"); assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa");
} }
#[cfg(not(windows))]
fn fhs() -> std::path::PathBuf {
std::path::PathBuf::from("/var/lib/numa")
}
#[cfg(not(windows))]
#[test]
fn suggested_config_path_prefers_home() {
assert_eq!(
resolve_suggested_config_path(Some("/home/alice"), fhs),
std::path::PathBuf::from("/home/alice/.config/numa/numa.toml"),
);
}
#[cfg(not(windows))]
#[test]
fn suggested_config_path_prefers_root_home_over_fhs() {
// Interactive root: HOME=/root is a real user context, not a daemon signal.
// Advisory must point where load_config will actually look — issue #81.
assert_eq!(
resolve_suggested_config_path(Some("/root"), fhs),
std::path::PathBuf::from("/root/.config/numa/numa.toml"),
);
}
#[cfg(not(windows))]
#[test]
fn suggested_config_path_falls_back_when_home_unset() {
assert_eq!(
resolve_suggested_config_path(None, fhs),
std::path::PathBuf::from("/var/lib/numa/numa.toml"),
);
}
#[cfg(not(windows))]
#[test]
fn suggested_config_path_falls_back_when_home_is_root() {
// systemd services sometimes have HOME=/ — don't treat that as a real home.
assert_eq!(
resolve_suggested_config_path(Some("/"), fhs),
std::path::PathBuf::from("/var/lib/numa/numa.toml"),
);
}
#[cfg(not(windows))]
#[test]
fn suggested_config_path_falls_back_when_home_is_empty() {
assert_eq!(
resolve_suggested_config_path(Some(""), fhs),
std::path::PathBuf::from("/var/lib/numa/numa.toml"),
);
}
#[cfg(not(windows))]
#[test]
fn suggested_config_path_skips_fallback_when_home_valid() {
// Happy path shouldn't probe the filesystem via config_dir().
let called = std::cell::Cell::new(false);
let fallback = || {
called.set(true);
std::path::PathBuf::from("/should/not/be/used")
};
let _ = resolve_suggested_config_path(Some("/home/alice"), fallback);
assert!(
!called.get(),
"fallback must not be invoked when HOME is valid"
);
}
} }

View File

@@ -15,7 +15,7 @@ use numa::forward::{parse_upstream, Upstream, UpstreamPool};
use numa::override_store::OverrideStore; use numa::override_store::OverrideStore;
use numa::query_log::QueryLog; use numa::query_log::QueryLog;
use numa::service_store::ServiceStore; use numa::service_store::ServiceStore;
use numa::stats::ServerStats; use numa::stats::{ServerStats, Transport};
use numa::system_dns::{ use numa::system_dns::{
discover_system_dns, install_service, restart_service, service_status, uninstall_service, discover_system_dns, install_service, restart_service, service_status, uninstall_service,
}; };
@@ -210,7 +210,13 @@ async fn main() -> numa::Result<()> {
} }
service_store.load_persisted(); service_store.load_persisted();
let forwarding_rules = system_dns.forwarding_rules; for fwd in &config.forwarding {
for suffix in &fwd.suffix {
info!("forwarding .{} to {} (config rule)", suffix, fwd.upstream);
}
}
let forwarding_rules =
numa::config::merge_forwarding_rules(&config.forwarding, system_dns.forwarding_rules)?;
// Resolve data_dir from config, falling back to the platform default. // Resolve data_dir from config, falling back to the platform default.
// Used for TLS CA storage below and stored on ServerCtx for runtime use. // Used for TLS CA storage below and stored on ServerCtx for runtime use.
@@ -279,6 +285,7 @@ async fn main() -> numa::Result<()> {
config.cache.min_ttl, config.cache.min_ttl,
config.cache.max_ttl, config.cache.max_ttl,
)), )),
refreshing: Mutex::new(std::collections::HashSet::new()),
stats: Mutex::new(ServerStats::new()), stats: Mutex::new(ServerStats::new()),
overrides: RwLock::new(OverrideStore::new()), overrides: RwLock::new(OverrideStore::new()),
blocklist: RwLock::new(blocklist), blocklist: RwLock::new(blocklist),
@@ -291,6 +298,7 @@ async fn main() -> numa::Result<()> {
upstream_port: config.upstream.port, upstream_port: config.upstream.port,
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
timeout: Duration::from_millis(config.upstream.timeout_ms), timeout: Duration::from_millis(config.upstream.timeout_ms),
hedge_delay: Duration::from_millis(config.upstream.hedge_ms),
proxy_tld_suffix: if config.proxy.tld.is_empty() { proxy_tld_suffix: if config.proxy.tld.is_empty() {
String::new() String::new()
} else { } else {
@@ -505,6 +513,14 @@ async fn main() -> numa::Result<()> {
}); });
} }
// Spawn DoH connection keepalive — prevents idle TLS teardown
{
let keepalive_ctx = Arc::clone(&ctx);
tokio::spawn(async move {
doh_keepalive_loop(keepalive_ctx).await;
});
}
// Spawn HTTP API server // Spawn HTTP API server
let api_ctx = Arc::clone(&ctx); let api_ctx = Arc::clone(&ctx);
let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?; let api_addr: SocketAddr = format!("{}:{}", config.server.api_bind_addr, api_port).parse()?;
@@ -584,7 +600,7 @@ async fn main() -> numa::Result<()> {
#[allow(clippy::infinite_loop)] #[allow(clippy::infinite_loop)]
loop { loop {
let mut buffer = BytePacketBuffer::new(); let mut buffer = BytePacketBuffer::new();
let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await { let (len, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await {
Ok(r) => r, Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => { Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {
// Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets // Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets
@@ -592,10 +608,9 @@ async fn main() -> numa::Result<()> {
} }
Err(e) => return Err(e.into()), Err(e) => return Err(e.into()),
}; };
let ctx = Arc::clone(&ctx); let ctx = Arc::clone(&ctx);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = handle_query(buffer, src_addr, &ctx).await { if let Err(e) = handle_query(buffer, len, src_addr, &ctx, Transport::Udp).await {
error!("{} | HANDLER ERROR | {}", src_addr, e); error!("{} | HANDLER ERROR | {}", src_addr, e);
} }
}); });
@@ -743,30 +758,22 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
} }
async fn warm_domain(ctx: &ServerCtx, domain: &str) { async fn warm_domain(ctx: &ServerCtx, domain: &str) {
use numa::question::QueryType; for qtype in [
numa::question::QueryType::A,
numa::question::QueryType::AAAA,
] {
numa::ctx::refresh_entry(ctx, domain, qtype).await;
}
}
for qtype in [QueryType::A, QueryType::AAAA] { async fn doh_keepalive_loop(ctx: Arc<ServerCtx>) {
let query = numa::packet::DnsPacket::query(0, domain, qtype); let mut interval = tokio::time::interval(Duration::from_secs(25));
let result = if ctx.upstream_mode == numa::config::UpstreamMode::Recursive { interval.tick().await; // skip first immediate tick
numa::recursive::resolve_recursive( loop {
domain, interval.tick().await;
qtype, let pool = ctx.upstream_pool.lock().unwrap().clone();
&ctx.cache, if let Some(upstream) = pool.preferred() {
&query, numa::forward::keepalive_doh(upstream).await;
&ctx.root_hints,
&ctx.srtt,
)
.await
} else {
let pool = ctx.upstream_pool.lock().unwrap().clone();
numa::forward::forward_with_failover(&query, &pool, &ctx.srtt, ctx.timeout).await
};
match result {
Ok(resp) => {
ctx.cache.write().unwrap().insert(domain, qtype, &resp);
log::debug!("cache warm: {} {:?}", domain, qtype);
}
Err(e) => log::warn!("cache warm: {} {:?} failed: {}", domain, qtype, e),
} }
} }
} }

View File

@@ -5,7 +5,7 @@ use std::time::SystemTime;
use crate::cache::DnssecStatus; use crate::cache::DnssecStatus;
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::question::QueryType; use crate::question::QueryType;
use crate::stats::QueryPath; use crate::stats::{QueryPath, Transport};
pub struct QueryLogEntry { pub struct QueryLogEntry {
pub timestamp: SystemTime, pub timestamp: SystemTime,
@@ -13,6 +13,7 @@ pub struct QueryLogEntry {
pub domain: String, pub domain: String,
pub query_type: QueryType, pub query_type: QueryType,
pub path: QueryPath, pub path: QueryPath,
pub transport: Transport,
pub rescode: ResultCode, pub rescode: ResultCode,
pub latency_us: u64, pub latency_us: u64,
pub dnssec: DnssecStatus, pub dnssec: DnssecStatus,
@@ -107,6 +108,7 @@ mod tests {
domain: "example.com".into(), domain: "example.com".into(),
query_type: QueryType::A, query_type: QueryType::A,
path: QueryPath::Forwarded, path: QueryPath::Forwarded,
transport: Transport::Udp,
rescode: ResultCode::NOERROR, rescode: ResultCode::NOERROR,
latency_us: 500, latency_us: 500,
dnssec: DnssecStatus::Indeterminate, dnssec: DnssecStatus::Indeterminate,

View File

@@ -15,8 +15,8 @@ use crate::srtt::SrttCache;
const MAX_REFERRAL_DEPTH: u8 = 10; const MAX_REFERRAL_DEPTH: u8 = 10;
const MAX_CNAME_DEPTH: u8 = 8; const MAX_CNAME_DEPTH: u8 = 8;
const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(800); const NS_QUERY_TIMEOUT: Duration = Duration::from_millis(400);
const TCP_TIMEOUT: Duration = Duration::from_millis(1500); const TCP_TIMEOUT: Duration = Duration::from_millis(400);
const UDP_FAIL_THRESHOLD: u8 = 3; const UDP_FAIL_THRESHOLD: u8 = 3;
static QUERY_ID: AtomicU16 = AtomicU16::new(1); static QUERY_ID: AtomicU16 = AtomicU16::new(1);
@@ -202,23 +202,24 @@ pub(crate) fn resolve_iterative<'a>(
let mut ns_idx = 0; let mut ns_idx = 0;
for _ in 0..MAX_REFERRAL_DEPTH { for _ in 0..MAX_REFERRAL_DEPTH {
let ns_addr = match ns_addrs.get(ns_idx) { if ns_idx >= ns_addrs.len() {
Some(addr) => *addr, return Err("no nameserver available".into());
None => return Err("no nameserver available".into()), }
};
let (q_name, q_type) = minimize_query(qname, qtype, &current_zone); let (q_name, q_type) = minimize_query(qname, qtype, &current_zone);
debug!( debug!(
"recursive: querying {} for {:?} {} (zone: {}, depth {})", "recursive: querying {} (+ hedge) for {:?} {} (zone: {}, depth {})",
ns_addr, q_type, q_name, current_zone, referral_depth ns_addrs[ns_idx], q_type, q_name, current_zone, referral_depth
); );
let response = match send_query(q_name, q_type, ns_addr, srtt).await { let response = match send_query_hedged(q_name, q_type, &ns_addrs[ns_idx..], srtt).await
{
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
debug!("recursive: NS {} failed: {}", ns_addr, e); debug!("recursive: NS query failed: {}", e);
ns_idx += 1; let remaining = ns_addrs.len().saturating_sub(ns_idx);
ns_idx += remaining.min(2);
continue; continue;
} }
}; };
@@ -228,6 +229,9 @@ pub(crate) fn resolve_iterative<'a>(
{ {
if let Some(zone) = referral_zone(&response) { if let Some(zone) = referral_zone(&response) {
current_zone = zone; current_zone = zone;
let mut cache_w = cache.write().unwrap();
cache_ns_delegation(&mut cache_w, &current_zone, &response);
drop(cache_w);
} }
let mut all_ns = extract_ns_from_records(&response.answers); let mut all_ns = extract_ns_from_records(&response.answers);
if all_ns.is_empty() { if all_ns.is_empty() {
@@ -296,6 +300,7 @@ pub(crate) fn resolve_iterative<'a>(
{ {
let mut cache_w = cache.write().unwrap(); let mut cache_w = cache.write().unwrap();
cache_ns_delegation(&mut cache_w, &current_zone, &response);
cache_ds_from_authority(&mut cache_w, &response); cache_ds_from_authority(&mut cache_w, &response);
} }
let mut new_ns_addrs = resolve_ns_addrs_from_glue(&response, &ns_names, cache); let mut new_ns_addrs = resolve_ns_addrs_from_glue(&response, &ns_names, cache);
@@ -560,6 +565,23 @@ fn cache_ds_from_authority(cache: &mut DnsCache, response: &DnsPacket) {
} }
} }
/// Cache NS delegation records from a referral response so that
/// `find_closest_ns` can skip re-querying TLD servers on subsequent lookups.
fn cache_ns_delegation(cache: &mut DnsCache, zone: &str, response: &DnsPacket) {
let ns_records: Vec<_> = response
.authorities
.iter()
.filter(|r| matches!(r, DnsRecord::NS { .. }))
.cloned()
.collect();
if ns_records.is_empty() {
return;
}
let mut pkt = make_glue_packet();
pkt.answers = ns_records;
cache.insert(zone, QueryType::NS, &pkt);
}
fn make_glue_packet() -> DnsPacket { fn make_glue_packet() -> DnsPacket {
let mut pkt = DnsPacket::new(); let mut pkt = DnsPacket::new();
pkt.header.response = true; pkt.header.response = true;
@@ -587,6 +609,115 @@ async fn tcp_with_srtt(
} }
} }
/// Smart NS query: fire to two servers simultaneously when SRTT is unknown
/// (cold queries), or to the best server with SRTT-based hedge when known.
async fn send_query_hedged(
qname: &str,
qtype: QueryType,
servers: &[SocketAddr],
srtt: &RwLock<SrttCache>,
) -> crate::Result<DnsPacket> {
if servers.is_empty() {
return Err("no nameserver available".into());
}
if servers.len() == 1 {
return send_query(qname, qtype, servers[0], srtt).await;
}
let primary = servers[0];
let secondary = servers[1];
let primary_known = srtt.read().unwrap().is_known(primary.ip());
if !primary_known {
// Cold: fire both simultaneously, first response wins
debug!(
"recursive: parallel query to {} and {} for {:?} {}",
primary, secondary, qtype, qname
);
let fut_a = send_query(qname, qtype, primary, srtt);
let fut_b = send_query(qname, qtype, secondary, srtt);
tokio::pin!(fut_a);
tokio::pin!(fut_b);
// First Ok wins. If one errors, wait for the other.
let mut a_done = false;
let mut b_done = false;
let mut a_err: Option<crate::Error> = None;
let mut b_err: Option<crate::Error> = None;
loop {
tokio::select! {
r = &mut fut_a, if !a_done => {
match r {
Ok(resp) => return Ok(resp),
Err(e) => { a_done = true; a_err = Some(e); }
}
}
r = &mut fut_b, if !b_done => {
match r {
Ok(resp) => return Ok(resp),
Err(e) => { b_done = true; b_err = Some(e); }
}
}
}
match (a_err.take(), b_err.take()) {
(Some(e), Some(_)) => return Err(e),
(a, b) => {
a_err = a;
b_err = b;
}
}
}
} else {
// Warm: send to best, hedge after SRTT × 3 if slow
let hedge_ms = srtt.read().unwrap().get(primary.ip()) * 3;
let hedge_delay = Duration::from_millis(hedge_ms.max(50));
let fut_a = send_query(qname, qtype, primary, srtt);
tokio::pin!(fut_a);
let delay = tokio::time::sleep(hedge_delay);
tokio::pin!(delay);
tokio::select! {
r = &mut fut_a => return r,
_ = &mut delay => {}
}
debug!(
"recursive: hedging {} -> {} after {}ms for {:?} {}",
primary, secondary, hedge_ms, qtype, qname
);
let fut_b = send_query(qname, qtype, secondary, srtt);
tokio::pin!(fut_b);
// First Ok wins; if one errors, wait for the other.
let mut a_err: Option<crate::Error> = None;
let mut b_err: Option<crate::Error> = None;
loop {
tokio::select! {
r = &mut fut_a, if a_err.is_none() => {
match r {
Ok(resp) => return Ok(resp),
Err(e) => {
if b_err.is_some() { return Err(e); }
a_err = Some(e);
}
}
}
r = &mut fut_b, if b_err.is_none() => {
match r {
Ok(resp) => return Ok(resp),
Err(e) => {
if let Some(ae) = a_err.take() { return Err(ae); }
b_err = Some(e);
}
}
}
}
}
}
}
async fn send_query( async fn send_query(
qname: &str, qname: &str,
qtype: QueryType, qtype: QueryType,
@@ -634,9 +765,13 @@ async fn send_query(
"send_query: {} consecutive UDP failures — switching to TCP-first", "send_query: {} consecutive UDP failures — switching to TCP-first",
fails fails
); );
// Now that UDP is disabled, retry this query via TCP
return tcp_with_srtt(&query, server, srtt, start).await;
} }
debug!("send_query: UDP failed for {}: {}, trying TCP", server, e); // UDP works in general (priming succeeded) but this server timed out.
tcp_with_srtt(&query, server, srtt, start).await // Don't waste another 400ms on TCP — the server is unreachable.
srtt.write().unwrap().record_failure(server.ip());
Err(e)
} }
} }
} }
@@ -678,6 +813,10 @@ mod tests {
use super::*; use super::*;
use std::net::{Ipv4Addr, Ipv6Addr}; use std::net::{Ipv4Addr, Ipv6Addr};
/// Tests that mutate the global UDP_DISABLED / UDP_FAILURES flags must hold
/// this lock to avoid racing with each other under `cargo test` parallelism.
static UDP_STATE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test] #[test]
fn extract_ns_from_authority() { fn extract_ns_from_authority() {
let mut pkt = DnsPacket::new(); let mut pkt = DnsPacket::new();
@@ -916,10 +1055,11 @@ mod tests {
} }
/// TCP-only server returns authoritative answer directly. /// TCP-only server returns authoritative answer directly.
/// Verifies: UDP fails → TCP fallback → resolves. /// Verifies: when UDP is disabled, TCP-first resolves.
#[tokio::test] #[tokio::test]
async fn tcp_fallback_resolves_when_udp_blocked() { async fn tcp_fallback_resolves_when_udp_blocked() {
UDP_DISABLED.store(false, Ordering::Relaxed); let _guard = UDP_STATE_LOCK.lock().unwrap();
UDP_DISABLED.store(true, Ordering::Relaxed);
UDP_FAILURES.store(0, Ordering::Release); UDP_FAILURES.store(0, Ordering::Release);
let server_addr = spawn_tcp_dns_server(|query| { let server_addr = spawn_tcp_dns_server(|query| {
@@ -950,49 +1090,32 @@ mod tests {
} }
} }
/// Full iterative resolution through TCP-only mock: root referral → authoritative answer. /// TCP round-trip through mock: query → authoritative answer via forward_tcp.
/// The mock plays both roles (returns referral for NS queries, answer for A queries). /// Uses forward_tcp directly to avoid dependence on the global UDP_DISABLED flag
/// which is shared across concurrent tests.
#[tokio::test] #[tokio::test]
async fn tcp_only_iterative_resolution() { async fn tcp_only_iterative_resolution() {
UDP_DISABLED.store(true, Ordering::Release); // Skip UDP entirely for speed
let server_addr = spawn_tcp_dns_server(|query| { let server_addr = spawn_tcp_dns_server(|query| {
let q = match query.questions.first() { let q = match query.questions.first() {
Some(q) => q, Some(q) => q,
None => return DnsPacket::response_from(query, ResultCode::SERVFAIL), None => return DnsPacket::response_from(query, ResultCode::SERVFAIL),
}; };
if q.qtype == QueryType::NS || q.name == "com" { let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
// Return referral — NS points back to ourselves (same IP, port 53 in glue resp.header.authoritative_answer = true;
// won't work, but cache will have our address from root_hints) resp.answers.push(DnsRecord::A {
let mut resp = DnsPacket::new(); domain: q.name.clone(),
resp.header.id = query.header.id; addr: Ipv4Addr::new(10, 0, 0, 42),
resp.header.response = true; ttl: 300,
resp.header.rescode = ResultCode::NOERROR; });
resp.questions = query.questions.clone(); resp
resp.authorities.push(DnsRecord::NS {
domain: "com".into(),
host: "ns1.com".into(),
ttl: 3600,
});
resp
} else {
// Return authoritative answer
let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
resp.header.authoritative_answer = true;
resp.answers.push(DnsRecord::A {
domain: q.name.clone(),
addr: Ipv4Addr::new(10, 0, 0, 42),
ttl: 300,
});
resp
}
}) })
.await; .await;
let srtt = RwLock::new(SrttCache::new(true)); let query = DnsPacket::query(0x1234, "hello.example.com", QueryType::A);
let result = send_query("hello.example.com", QueryType::A, server_addr, &srtt).await; let resp = crate::forward::forward_tcp(&query, server_addr, TCP_TIMEOUT)
let resp = result.expect("TCP-only send_query should work"); .await
.expect("TCP query should work");
assert_eq!(resp.header.rescode, ResultCode::NOERROR); assert_eq!(resp.header.rescode, ResultCode::NOERROR);
match &resp.answers[0] { match &resp.answers[0] {
DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)), DnsRecord::A { addr, .. } => assert_eq!(*addr, Ipv4Addr::new(10, 0, 0, 42)),
@@ -1002,7 +1125,8 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn tcp_fallback_handles_nxdomain() { async fn tcp_fallback_handles_nxdomain() {
UDP_DISABLED.store(false, Ordering::Relaxed); let _guard = UDP_STATE_LOCK.lock().unwrap();
UDP_DISABLED.store(true, Ordering::Relaxed);
UDP_FAILURES.store(0, Ordering::Release); UDP_FAILURES.store(0, Ordering::Release);
let server_addr = spawn_tcp_dns_server(|query| { let server_addr = spawn_tcp_dns_server(|query| {
@@ -1034,6 +1158,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn udp_auto_disable_resets() { async fn udp_auto_disable_resets() {
let _guard = UDP_STATE_LOCK.lock().unwrap();
UDP_DISABLED.store(true, Ordering::Release); UDP_DISABLED.store(true, Ordering::Release);
UDP_FAILURES.store(5, Ordering::Relaxed); UDP_FAILURES.store(5, Ordering::Relaxed);

View File

@@ -45,6 +45,11 @@ impl SrttCache {
} }
} }
/// Whether we have observed RTT data for this IP.
pub fn is_known(&self, ip: IpAddr) -> bool {
self.entries.contains_key(&ip)
}
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL. /// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
fn decayed_srtt(entry: &SrttEntry) -> u64 { fn decayed_srtt(entry: &SrttEntry) -> u64 {
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs()) Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())

View File

@@ -97,9 +97,32 @@ pub struct ServerStats {
queries_local: u64, queries_local: u64,
queries_overridden: u64, queries_overridden: u64,
upstream_errors: u64, upstream_errors: u64,
transport_udp: u64,
transport_tcp: u64,
transport_dot: u64,
transport_doh: u64,
started_at: Instant, started_at: Instant,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Transport {
Udp,
Tcp,
Dot,
Doh,
}
impl Transport {
pub fn as_str(&self) -> &'static str {
match self {
Transport::Udp => "UDP",
Transport::Tcp => "TCP",
Transport::Dot => "DOT",
Transport::Doh => "DOH",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueryPath { pub enum QueryPath {
Local, Local,
@@ -167,11 +190,15 @@ impl ServerStats {
queries_local: 0, queries_local: 0,
queries_overridden: 0, queries_overridden: 0,
upstream_errors: 0, upstream_errors: 0,
transport_udp: 0,
transport_tcp: 0,
transport_dot: 0,
transport_doh: 0,
started_at: Instant::now(), started_at: Instant::now(),
} }
} }
pub fn record(&mut self, path: QueryPath) -> u64 { pub fn record(&mut self, path: QueryPath, transport: Transport) -> u64 {
self.queries_total += 1; self.queries_total += 1;
match path { match path {
QueryPath::Local => self.queries_local += 1, QueryPath::Local => self.queries_local += 1,
@@ -183,6 +210,12 @@ impl ServerStats {
QueryPath::Overridden => self.queries_overridden += 1, QueryPath::Overridden => self.queries_overridden += 1,
QueryPath::UpstreamError => self.upstream_errors += 1, QueryPath::UpstreamError => self.upstream_errors += 1,
} }
match transport {
Transport::Udp => self.transport_udp += 1,
Transport::Tcp => self.transport_tcp += 1,
Transport::Dot => self.transport_dot += 1,
Transport::Doh => self.transport_doh += 1,
}
self.queries_total self.queries_total
} }
@@ -206,6 +239,10 @@ impl ServerStats {
overridden: self.queries_overridden, overridden: self.queries_overridden,
blocked: self.queries_blocked, blocked: self.queries_blocked,
errors: self.upstream_errors, errors: self.upstream_errors,
transport_udp: self.transport_udp,
transport_tcp: self.transport_tcp,
transport_dot: self.transport_dot,
transport_doh: self.transport_doh,
} }
} }
@@ -242,4 +279,8 @@ pub struct StatsSnapshot {
pub overridden: u64, pub overridden: u64,
pub blocked: u64, pub blocked: u64,
pub errors: u64, pub errors: u64,
pub transport_udp: u64,
pub transport_tcp: u64,
pub transport_dot: u64,
pub transport_doh: u64,
} }

View File

@@ -2,6 +2,8 @@ use std::net::SocketAddr;
use log::info; use log::info;
use crate::forward::Upstream;
fn print_recursive_hint() { fn print_recursive_hint() {
let is_recursive = crate::config::load_config("numa.toml") let is_recursive = crate::config::load_config("numa.toml")
.map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive) .map(|c| c.config.upstream.mode == crate::config::UpstreamMode::Recursive)
@@ -22,7 +24,18 @@ fn is_loopback_or_stub(addr: &str) -> bool {
pub struct ForwardingRule { pub struct ForwardingRule {
pub suffix: String, pub suffix: String,
dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching dot_suffix: String, // pre-computed ".suffix" for zero-alloc matching
pub upstream: SocketAddr, pub upstream: Upstream,
}
impl ForwardingRule {
pub fn new(suffix: String, upstream: Upstream) -> Self {
let dot_suffix = format!(".{}", suffix);
Self {
suffix,
dot_suffix,
upstream,
}
}
} }
/// Result of system DNS discovery — default upstream + conditional forwarding rules. /// Result of system DNS discovery — default upstream + conditional forwarding rules.
@@ -91,7 +104,7 @@ pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<Stri
sudo numa install (on Windows, run as Administrator) sudo numa install (on Windows, run as Administrator)
2. Run on a non-privileged port for testing. 2. Run on a non-privileged port for testing.
Create ~/.config/numa/numa.toml with: Create {} with:
[server] [server]
bind_addr = \"127.0.0.1:5354\" bind_addr = \"127.0.0.1:5354\"
@@ -100,7 +113,8 @@ pub fn try_port53_advisory(bind_addr: &str, err: &std::io::Error) -> Option<Stri
Then run: numa Then run: numa
Test with: dig @127.0.0.1 -p 5354 example.com Test with: dig @127.0.0.1 -p 5354 example.com
" ",
crate::suggested_config_path().display()
)) ))
} }
@@ -220,12 +234,8 @@ fn discover_macos() -> SystemDnsInfo {
#[cfg(any(target_os = "macos", target_os = "linux"))] #[cfg(any(target_os = "macos", target_os = "linux"))]
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> { fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?; let addr = crate::forward::parse_upstream_addr(nameserver, 53).ok()?;
Some(ForwardingRule { Some(ForwardingRule::new(domain.to_string(), Upstream::Udp(addr)))
dot_suffix: format!(".{}", domain),
suffix: domain.to_string(),
upstream: addr,
})
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
@@ -814,10 +824,13 @@ fn uninstall_windows() -> Result<(), String> {
/// Find the upstream for a domain by checking forwarding rules. /// Find the upstream for a domain by checking forwarding rules.
/// Returns None if no rule matches (use default upstream). /// Returns None if no rule matches (use default upstream).
/// Zero-allocation on the hot path — dot_suffix is pre-computed. /// Zero-allocation on the hot path — dot_suffix is pre-computed.
pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<SocketAddr> { pub fn match_forwarding_rule<'a>(
domain: &str,
rules: &'a [ForwardingRule],
) -> Option<&'a Upstream> {
for rule in rules { for rule in rules {
if domain == rule.suffix || domain.ends_with(&rule.dot_suffix) { if domain == rule.suffix || domain.ends_with(&rule.dot_suffix) {
return Some(rule.upstream); return Some(&rule.upstream);
} }
} }
None None

95
src/testutil.rs Normal file
View File

@@ -0,0 +1,95 @@
use std::collections::{HashMap, HashSet};
use std::net::{Ipv4Addr, SocketAddr};
use std::path::PathBuf;
use std::sync::{Mutex, RwLock};
use std::time::Duration;
use tokio::net::UdpSocket;
use crate::blocklist::BlocklistStore;
use crate::buffer::BytePacketBuffer;
use crate::cache::DnsCache;
use crate::config::UpstreamMode;
use crate::ctx::ServerCtx;
use crate::forward::{Upstream, UpstreamPool};
use crate::health::HealthMeta;
use crate::lan::PeerStore;
use crate::override_store::OverrideStore;
use crate::packet::DnsPacket;
use crate::query_log::QueryLog;
use crate::service_store::ServiceStore;
use crate::srtt::SrttCache;
use crate::stats::ServerStats;
/// Minimal `ServerCtx` for tests. Override fields after construction
/// (all fields are `pub`), then wrap in `Arc`.
pub async fn test_ctx() -> ServerCtx {
let socket = UdpSocket::bind("127.0.0.1:0").await.unwrap();
ServerCtx {
socket,
zone_map: HashMap::new(),
cache: RwLock::new(DnsCache::new(100, 60, 86400)),
refreshing: Mutex::new(HashSet::new()),
stats: Mutex::new(ServerStats::new()),
overrides: RwLock::new(OverrideStore::new()),
blocklist: RwLock::new(BlocklistStore::new()),
query_log: Mutex::new(QueryLog::new(100)),
services: Mutex::new(ServiceStore::new()),
lan_peers: Mutex::new(PeerStore::new(90)),
forwarding_rules: Vec::new(),
upstream_pool: Mutex::new(UpstreamPool::new(
vec![Upstream::Udp("127.0.0.1:53".parse().unwrap())],
vec![],
)),
upstream_auto: false,
upstream_port: 53,
lan_ip: Mutex::new(Ipv4Addr::LOCALHOST),
timeout: Duration::from_millis(200),
hedge_delay: Duration::ZERO,
proxy_tld: "numa".to_string(),
proxy_tld_suffix: ".numa".to_string(),
lan_enabled: false,
config_path: "/tmp/test-numa.toml".to_string(),
config_found: false,
config_dir: PathBuf::from("/tmp"),
data_dir: PathBuf::from("/tmp"),
tls_config: None,
upstream_mode: UpstreamMode::Forward,
root_hints: Vec::new(),
srtt: RwLock::new(SrttCache::new(true)),
inflight: Mutex::new(HashMap::new()),
dnssec_enabled: false,
dnssec_strict: false,
health_meta: HealthMeta::test_fixture(),
ca_pem: None,
mobile_enabled: false,
mobile_port: 8765,
}
}
/// Spawn a UDP socket that replies to the first DNS query with the given
/// response packet (patching the query ID to match). Returns the socket address.
pub async fn mock_upstream(response: DnsPacket) -> SocketAddr {
let sock = UdpSocket::bind("127.0.0.1:0").await.unwrap();
let addr = sock.local_addr().unwrap();
tokio::spawn(async move {
let mut buf = [0u8; 512];
let (_, src) = sock.recv_from(&mut buf).await.unwrap();
let query_id = u16::from_be_bytes([buf[0], buf[1]]);
let mut resp = response;
resp.header.id = query_id;
let mut out = BytePacketBuffer::new();
resp.write(&mut out).unwrap();
sock.send_to(out.filled(), src).await.unwrap();
});
addr
}
/// UDP socket that accepts connections but never replies.
/// Useful as an upstream that triggers timeouts.
pub fn blackhole_upstream() -> SocketAddr {
let sock = std::net::UdpSocket::bind("127.0.0.1:0").unwrap();
let addr = sock.local_addr().unwrap();
// Leak so it stays bound for the duration of the test process.
Box::leak(Box::new(sock));
addr
}

View File

@@ -66,13 +66,14 @@ pub fn try_data_dir_advisory(err: &crate::Error, data_dir: &Path) -> Option<Stri
sudo numa install (on Windows, run as Administrator) sudo numa install (on Windows, run as Administrator)
2. Point data_dir at a path you can write. 2. Point data_dir at a path you can write.
Create ~/.config/numa/numa.toml with: Create {} with:
[server] [server]
data_dir = \"/path/you/can/write\" data_dir = \"/path/you/can/write\"
", ",
data_dir.display() data_dir.display(),
crate::suggested_config_path().display()
)) ))
} }
@@ -185,8 +186,19 @@ fn generate_service_cert(
} }
} }
if sans.is_empty() { // Loopback IP SANs so browsers can reach DoH at https://127.0.0.1/dns-query
return Err("no valid service names for TLS cert".into()); sans.push(SanType::IpAddress(std::net::IpAddr::V4(
std::net::Ipv4Addr::LOCALHOST,
)));
sans.push(SanType::IpAddress(std::net::IpAddr::V6(
std::net::Ipv6Addr::LOCALHOST,
)));
for name in ["localhost", tld] {
match name.to_string().try_into() {
Ok(ia5) => sans.push(SanType::DnsName(ia5)),
Err(e) => warn!("invalid SAN {}: {}", name, e),
}
} }
params.subject_alt_names = sans; params.subject_alt_names = sans;
@@ -239,4 +251,72 @@ mod tests {
let err: crate::Error = "rcgen failure".into(); let err: crate::Error = "rcgen failure".into();
assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none()); assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none());
} }
#[test]
fn service_cert_contains_expected_sans() {
use x509_parser::prelude::GeneralName;
let dir = std::env::temp_dir().join(format!("numa-test-san-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (ca_der, issuer) = ensure_ca(&dir).unwrap();
let names = vec!["grafana".into(), "router".into()];
let (chain, _) = generate_service_cert(&ca_der, &issuer, "numa", &names).unwrap();
assert_eq!(chain.len(), 2, "chain should be [leaf, CA]");
let (_, cert) = x509_parser::parse_x509_certificate(chain[0].as_ref()).unwrap();
let san = cert
.tbs_certificate
.subject_alternative_name()
.unwrap()
.unwrap();
let dns: Vec<&str> = san
.value
.general_names
.iter()
.filter_map(|gn| match gn {
GeneralName::DNSName(s) => Some(*s),
_ => None,
})
.collect();
let ips: Vec<std::net::IpAddr> = san
.value
.general_names
.iter()
.filter_map(|gn| match gn {
GeneralName::IPAddress(b) => match b.len() {
4 => Some(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
b[0], b[1], b[2], b[3],
))),
16 => {
let a: [u8; 16] = (*b).try_into().unwrap();
Some(std::net::IpAddr::V6(std::net::Ipv6Addr::from(a)))
}
_ => None,
},
_ => None,
})
.collect();
// DNS SANs
assert!(dns.contains(&"*.numa"), "missing wildcard SAN");
assert!(dns.contains(&"grafana.numa"), "missing service SAN");
assert!(dns.contains(&"router.numa"), "missing service SAN");
assert!(dns.contains(&"localhost"), "missing localhost SAN");
assert!(dns.contains(&"numa"), "missing bare TLD SAN");
// IP SANs
assert!(
ips.contains(&std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)),
"missing 127.0.0.1 SAN"
);
assert!(
ips.contains(&std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)),
"missing ::1 SAN"
);
let _ = std::fs::remove_dir_all(&dir);
}
} }

1416
src/wire.rs Normal file

File diff suppressed because it is too large Load Diff

5
tests/docker/hold53.py Normal file
View File

@@ -0,0 +1,5 @@
import socket, signal
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 0)
s.bind(("", 53))
signal.pause()

164
tests/docker/issue-81.sh Executable file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env bash
#
# End-to-end validation of the issue #81 fix (config path advisory).
#
# Builds numa from two source trees — the buggy baseline and the fix
# candidate — inside one debian:bookworm container, then runs four
# scenarios to prove:
#
# 1. replication/main — reporter's sequence, bug confirmed
# 2. replication/fix — reporter's sequence, bug is gone
# 3. existing/main — pre-installed config at FHS data dir still loads
# 4. existing/fix — same, unchanged by the fix (no regression)
#
# Scenarios 3 and 4 guard against the fear that the fix might change
# candidate order and break existing daemon installs (including the
# macOS Homebrew-prefix layout at /usr/local/var/numa/).
#
# Usage:
# MAIN_SRC=/path/to/main-checkout FIX_SRC=/path/to/fix-worktree \
# ./tests/docker/issue-81.sh
#
# Defaults: MAIN_SRC = $(git rev-parse --show-toplevel), FIX_SRC = same.
set -euo pipefail
MAIN_SRC="${MAIN_SRC:-$(git rev-parse --show-toplevel)}"
FIX_SRC="${FIX_SRC:-$MAIN_SRC}"
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
echo "── issue #81 validation ──"
echo " main: $MAIN_SRC"
echo " fix: $FIX_SRC"
echo
docker run --rm \
--platform linux/amd64 \
-v "$MAIN_SRC:/main:ro" \
-v "$FIX_SRC:/fix:ro" \
-v "$(dirname "$0")/hold53.py:/tmp/hold53.py:ro" \
-v numa-port53-cargo:/root/.cargo \
-v numa-port53-target:/work/target \
debian:bookworm bash -c '
set -euo pipefail
# Paths and ports used by all scenarios — keep in one place so the
# heredocs and the verdict greps cannot drift.
XDG_CONFIG="/root/.config/numa/numa.toml"
FHS_CONFIG="/var/lib/numa/numa.toml"
TEST_PORT="5354"
TEST_API_PORT="5380"
apt-get update -qq && apt-get install -y -qq curl build-essential python3 2>&1 | tail -1
if ! command -v cargo &>/dev/null; then
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet
fi
. "$HOME/.cargo/env"
build_from() {
local label="$1"; local src="$2"
mkdir -p "/work/$label"
tar -C "$src" --exclude=./target --exclude=./.git -cf - . | tar -C "/work/$label" -xf -
(cd "/work/$label" && cargo build --release --locked 2>&1 | tail -1)
cp "/work/$label/target/release/numa" "/work/numa-$label"
}
build_from main /main
build_from fix /fix
holder=0
stop_holder() {
if [ "$holder" -ne 0 ]; then
kill "$holder" 2>/dev/null || true
wait "$holder" 2>/dev/null || true
holder=0
fi
}
trap stop_holder EXIT
start_holder() {
python3 /tmp/hold53.py &
holder=$!
sleep 0.3
}
write_test_config() {
local path="$1"
mkdir -p "$(dirname "$path")"
cat > "$path" <<EOF
[server]
bind_addr = "127.0.0.1:$TEST_PORT"
api_port = $TEST_API_PORT
EOF
}
verdict() {
local label="$1"; local expected="$2"; local file="$3"
# "cannot bind to" is printed by the advisory when numa fails to start.
# Its absence is a reliable proxy for "numa bound successfully" because
# the banner-only log we capture contains no other failure surface.
if grep -q "cannot bind to" "$file"; then
echo " [$label] did not bind $TEST_PORT — numa ignored the XDG config"
[ "$expected" = "ignored" ] && return 0 || return 1
else
echo " [$label] bound $TEST_PORT — config loaded"
[ "$expected" = "bound" ] && return 0 || return 1
fi
}
scenario_replication() {
local label="$1"; local bin="/work/numa-$label"; local expected="$2"
echo
echo "════════ REPLICATION / $label ════════"
rm -rf /root/.config/numa /var/lib/numa
mkdir -p "$(dirname "$XDG_CONFIG")"
start_holder
set +e
timeout 5 "$bin" > /tmp/run1.txt 2>&1
set -e
echo "── step 1: advisory printed by $label ──"
grep -E "Create .* with:" /tmp/run1.txt | sed "s/^/ /" || echo " <no advisory line>"
write_test_config "$XDG_CONFIG"
echo "── step 2: wrote config at $XDG_CONFIG ──"
set +e
timeout 3 "$bin" > /tmp/run2.txt 2>&1
set -e
stop_holder
verdict "$label" "$expected" /tmp/run2.txt
}
scenario_existing_install() {
local label="$1"; local bin="/work/numa-$label"
echo
echo "════════ EXISTING INSTALL / $label ════════"
rm -rf /root/.config/numa /var/lib/numa
write_test_config "$FHS_CONFIG"
start_holder
set +e
timeout 3 "$bin" > /tmp/run.txt 2>&1
set -e
stop_holder
verdict "$label" "bound" /tmp/run.txt
}
RC=0
scenario_replication main ignored || RC=1
scenario_replication fix bound || RC=1
scenario_existing_install main || RC=1
scenario_existing_install fix || RC=1
echo
if [ "$RC" -eq 0 ]; then
echo "── all scenarios matched expectations ──"
else
echo "── FAILURE: one or more scenarios diverged ──"
fi
exit $RC
'

View File

@@ -53,7 +53,17 @@ CONF
echo "Starting Numa on :$PORT ($SUITE_NAME)..." echo "Starting Numa on :$PORT ($SUITE_NAME)..."
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 & RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
NUMA_PID=$! NUMA_PID=$!
sleep 4 sleep 2
# Wait for blocklist to load (if blocking is enabled in this suite)
if echo "$SUITE_CONFIG" | grep -q 'enabled = true'; then
for i in $(seq 1 20); do
LOADED=$(curl -sf http://127.0.0.1:$API_PORT/blocking/stats 2>/dev/null \
| grep -o '"domains_loaded":[0-9]*' | cut -d: -f2)
if [ "${LOADED:-0}" -gt 0 ]; then break; fi
sleep 1
done
fi
if ! kill -0 "$NUMA_PID" 2>/dev/null; then if ! kill -0 "$NUMA_PID" 2>/dev/null; then
echo "Failed to start Numa:" echo "Failed to start Numa:"