50 Commits

Author SHA1 Message Date
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
Razvan Dimescu
2de1bc2efc chore: bump version to 0.12.0 2026-04-11 12:15:40 +03:00
Razvan Dimescu
156b68de87 fix: replace unscannable QR art with placeholder in blog post (#80)
The Unicode block-character QR code in the DoT blog post can't be
scanned by phone cameras due to HTML font metrics distorting the grid.
Replace with a bordered placeholder box — the dashboard screenshot
already shows a working QR.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 04:17:46 +03:00
Razvan Dimescu
7d6b0ed568 feat: DoH server endpoint + DoT enabled by default (#79)
* chore: document multi-forwarder and cache warming in config and README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: DNS-over-HTTPS server endpoint (RFC 8484)

Serve DoH at POST /dns-query on the existing HTTPS proxy (port 443).
Automatically enabled when proxy TLS is active — no config needed.
Also fix zone map priority so local zones override RFC 6762 .local
special-use handling.

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

* style: cargo fmt

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

* chore: remove GoatCounter analytics from site

GoatCounter domains (goatcounter.com, gc.zgo.at) are blocked by
Hagezi Pro, which is Numa's default blocklist. A DNS privacy tool
should not embed analytics that its own resolver blocks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: enable DoT listener by default

DoT now starts automatically with `sudo numa`, matching the proxy and
DoH which are already on by default. The self-signed CA infrastructure
is shared with the proxy, so there is no additional setup. This makes
`numa setup-phone` work out of the box.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 04:06:17 +03:00
Razvan Dimescu
7770129589 feat: cache warming — proactive DNS resolution for configured domains (#78)
Resolves A + AAAA at startup for domains listed in [cache] warm,
then re-resolves before TTL expiry (at 75% elapsed). Keeps critical
domains always hot in cache with zero client-visible latency.

Closes #34 (item 4)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 01:14:04 +03:00
Razvan Dimescu
8abcd91f95 feat: multi-forwarder with SRTT-based failover (#77)
* feat: multi-forwarder with SRTT-based failover

address accepts string or array, with optional per-server port override.
New fallback pool tried only when all primaries fail. Sequential failover
with SRTT ranking ensures fastest upstream is tried first.

Closes #34 (items 1, 2, 3)

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

* refactor: simplify failover candidate list and deduplicate recursive pool

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract maybe_update_primary for testable upstream re-detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: rustfmt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 00:26:58 +03:00
Razvan Dimescu
a96b84fdeb ci: use pandoc/actions/setup instead of apt-get (#76)
apt-get install pandoc took ~27 minutes due to apt index refresh.
The prebuilt binary action completes in seconds.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 23:16:59 +03:00
Razvan Dimescu
23ff3ce455 chore: blog full QR output + dashboard screenshot, hero script phone setup scene (#75)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:34:41 +03:00
Razvan Dimescu
2c20c56421 feat: mobile setup — QR onboarding, Wi-Fi scoped mobileconfig (#73)
* fix: scope mobileconfig DNS to Wi-Fi only via OnDemandRules

Without OnDemandRules, iOS applies the DoT profile globally —
cellular DNS breaks when the phone leaves the LAN.

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

* feat: phone setup QR code in dashboard header

- Add /qr endpoint serving SVG (uses existing qrcode crate, svg feature)
- Header popover: QR on desktop, direct download link on mobile viewports
- Only visible when [mobile] enabled = true in config
- Expose mobile.enabled and mobile.port in /stats response
- Lazy-load QR on first click, dismiss on outside click

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

* fix: add Cache-Control to /qr, re-fetch QR on each popover open

Cache-Control: no-store prevents stale QR after LAN IP change.
Remove qrLoaded flag so the QR always reflects the current IP.

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

* style: rustfmt serve_qr response tuple

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

* fix: add iOS install steps to phone setup popover

iOS shows "Profile Downloaded" with no guidance. The popover
now includes the 3-step install flow including the buried
Certificate Trust Settings toggle.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:21:51 +03:00
Razvan Dimescu
921ed68d54 fix: allowlist parent domain unblocks subdomains (#74)
* fix: allowlist parent domain unblocks subdomains in blocklist

The allowlist walk-up was interleaved with the blocklist walk-up,
so an exact blocklist match on www.example.com short-circuited
before reaching example.com in the allowlist. Now allowlist is
checked at all parent levels before consulting the blocklist.

Deduplicate is_blocked/check via find_in_set helper; is_blocked
delegates to check. Adds 7 new blocklist tests.

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

* style: rustfmt blocklist tests

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

* perf: zero-alloc is_blocked hot path, normalize trailing dots

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: rustfmt add_to_allowlist

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: extract normalize() for domain lowering + dot stripping

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:43:40 +03:00
38 changed files with 5445 additions and 323 deletions

View File

@@ -32,7 +32,7 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
- name: Install pandoc - name: Install pandoc
run: sudo apt-get install -y pandoc uses: pandoc/actions/setup@v1
- name: Generate blog HTML - name: Generate blog HTML
run: make blog run: make blog
- name: Setup Pages - name: Setup Pages

1
.gitignore vendored
View File

@@ -3,3 +3,4 @@
CLAUDE.md CLAUDE.md
docs/ docs/
site/blog/posts/ site/blog/posts/
ios/

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.11.0" version = "0.13.0"
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.11.0" version = "0.13.0"
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"
@@ -30,12 +30,16 @@ tokio-rustls = "0.26"
arc-swap = "1" 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 } 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

@@ -113,14 +113,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,10 +139,15 @@ 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] 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] 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
- [ ] Global `.numa` names — DHT-backed, no registrar - [ ] Global `.numa` names — DHT-backed, no registrar

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

@@ -132,20 +132,29 @@ $ numa setup-phone
Numa Phone Setup Numa Phone Setup
Profile URL: http://192.168.1.16:8765/mobileconfig Profile URL: http://192.168.1.10:8765/mobileconfig
▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀ ████████████████████████████
█▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █ █
... ██ [QR code rendered in ██
██ your terminal] ██
██ ██
██████████████████████████████
On your iPhone: On your iPhone:
1. Open Camera, point at the QR code, tap the yellow banner 1. Open Camera, point at the QR code, tap the yellow banner
2. Allow the download when Safari asks 2. Allow the download when Safari asks
3. Settings "Profile Downloaded" → Install 3. Open Settings — tap "Profile Downloaded" near the top
4. Settings → General → About → Certificate Trust Settings (or: Settings → General → VPN & Device Management → Numa DNS)
4. Tap Install (top right), enter passcode, Install again
5. Settings → General → About → Certificate Trust Settings
Toggle ON "Numa Local CA" — required for DoT to work Toggle ON "Numa Local CA" — required for DoT to work
``` ```
The same QR is available in the dashboard — click "Phone Setup" in the header and the popover renders an SVG QR code pointing at the mobileconfig URL. On mobile viewports it shows a direct download link instead.
<img src="../phone-setup-dashboard.png" alt="Numa dashboard with Phone Setup popover showing QR code and install instructions">
Step 4 is non-negotiable. Even though the CA is bundled in the same profile that installs the DNS settings, iOS still requires the user to explicitly toggle trust in Certificate Trust Settings. It's a deliberate iOS policy to prevent profile-based trust injection — annoying, and correct. Step 4 is non-negotiable. Even though the CA is bundled in the same profile that installs the DNS settings, iOS still requires the user to explicitly toggle trust in Certificate Trust Settings. It's a deliberate iOS policy to prevent profile-based trust injection — annoying, and correct.
I've been dogfooding this since v0.10 shipped in early April. The phone resolves through Numa over DoT whenever I'm home; persistent connections are visible in the log as a single source port living through dozens of queries. The one real caveat: if the laptop's LAN IP changes, the profile breaks. [RFC 9462 DDR](https://datatracker.ietf.org/doc/html/rfc9462) fixes that — Numa can respond to `_dns.resolver.arpa IN SVCB` with its current IP and iOS picks it up on each network join. Next piece of work. I've been dogfooding this since v0.10 shipped in early April. The phone resolves through Numa over DoT whenever I'm home; persistent connections are visible in the log as a single source port living through dozens of queries. The one real caveat: if the laptop's LAN IP changes, the profile breaks. [RFC 9462 DDR](https://datatracker.ietf.org/doc/html/rfc9462) fixes that — Numa can respond to `_dns.resolver.arpa IN SVCB` with its current IP and iOS picks it up on each network join. Next piece of work.
@@ -160,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

@@ -12,11 +12,18 @@ api_port = 5380
# [upstream] # [upstream]
# mode = "forward" # "forward" (default) — relay to upstream # mode = "forward" # "forward" (default) — relay to upstream
# # "recursive" — resolve from root hints (no address needed) # # "recursive" — resolve from root hints (no address needed)
# 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 = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted) # address = "https://dns.quad9.net/dns-query" # DNS-over-HTTPS (encrypted)
# address = "https://cloudflare-dns.com/dns-query" # Cloudflare DoH # address = "tls://9.9.9.9#dns.quad9.net" # DNS-over-TLS (encrypted, port 853)
# address = "9.9.9.9" # plain UDP # fallback = ["8.8.8.8", "1.1.1.1"] # tried only when all primaries fail
# port = 53 # only for forward mode, plain UDP # 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)
@@ -44,6 +51,14 @@ 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
# [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
@@ -51,9 +66,10 @@ 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
[proxy] [proxy]
enabled = true enabled = true
@@ -91,7 +107,7 @@ tld = "numa"
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853 # DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
# [dot] # [dot]
# enabled = false # opt-in: accept DoT queries # enabled = true # on by default; set false to disable
# port = 853 # standard DoT port # port = 853 # standard DoT port
# bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces # bind_addr = "0.0.0.0" # IPv4 or IPv6; unspecified binds all interfaces
# cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available) # cert_path = "/etc/numa/dot.crt" # PEM cert; omit to use self-signed (proxy CA if available)

View File

@@ -7,18 +7,19 @@
# The script: # The script:
# 1. Opens the dashboard in Chrome --app mode (clean, no address bar) # 1. Opens the dashboard in Chrome --app mode (clean, no address bar)
# 2. Generates DNS traffic (forward, cache hit, blocked) # 2. Generates DNS traffic (forward, cache hit, blocked)
# 3. Types "peekm" / "6419" into the Local Services form on camera # 3. Opens Phone Setup QR popover
# 4. Shows LAN accessibility badge ("local only" / "LAN") # 4. Types "peekm" / "6419" into the Local Services form on camera
# 5. Checks a blocked domain # 5. Shows LAN accessibility badge ("local only" / "LAN")
# 6. Opens peekm.numa to show the proxy working # 6. Checks a blocked domain
# 7. Records via ffmpeg and converts to optimized GIF # 7. Opens peekm.numa to show the proxy working
# 8. Records via ffmpeg and converts to optimized GIF
set -euo pipefail set -euo pipefail
# --------------- Configuration --------------- # --------------- Configuration ---------------
OUTPUT="${1:-assets/hero-demo.gif}" OUTPUT="${1:-assets/hero-demo.gif}"
PORT=5380 PORT=5380
RECORD_SECONDS=20 RECORD_SECONDS=24
VIEWPORT_W=1800 VIEWPORT_W=1800
VIEWPORT_H=1100 VIEWPORT_H=1100
FPS=12 FPS=12
@@ -230,8 +231,16 @@ dig @127.0.0.1 github.com +short > /dev/null 2>&1
dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1 dig @127.0.0.1 ad.doubleclick.net +short > /dev/null 2>&1
sleep 3 sleep 3
# --------------- Scene 2: Add peekm service via UI (3-7s) --------------- # --------------- Scene 2: Phone Setup popover (3-7s) ---------------
log "Scene 2: Adding peekm.numa service..." log "Scene 2: Phone Setup QR popover..."
run_js "document.querySelector('#phoneSetup button').click();"
sleep 3
# Dismiss popover
run_js "document.getElementById('phoneSetupPopover').style.display = 'none';"
sleep 1
# --------------- Scene 3: Add peekm service via UI (7-11s) ---------------
log "Scene 3: Adding peekm.numa service..."
# Services panel is now first — scroll to it # Services panel is now first — scroll to it
run_js " run_js "
@@ -249,18 +258,18 @@ sleep 0.3
run_js "document.querySelector('#serviceForm .btn-add').click();" run_js "document.querySelector('#serviceForm .btn-add').click();"
sleep 2 sleep 2
# --------------- Scene 3: Open peekm.numa (7-11s) --------------- # --------------- Scene 4: Open peekm.numa (11-15s) ---------------
log "Scene 3: Opening peekm.numa in browser..." log "Scene 4: Opening peekm.numa in browser..."
open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true open "http://peekm.numa/view/peekm/README.md" 2>/dev/null || true
sleep 4 sleep 4
# --------------- Scene 4: Back to dashboard (11-14s) --------------- # --------------- Scene 5: Back to dashboard (15-18s) ---------------
log "Scene 4: Back to dashboard — LAN badges + LOCAL queries visible..." log "Scene 5: Back to dashboard — LAN badges + LOCAL queries visible..."
osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true osascript -e "tell application \"System Events\" to set frontmost of (first process whose unix id is $CHROME_PID) to true" 2>/dev/null || true
sleep 3 sleep 3
# --------------- Scene 5: Check Domain blocker (14-17s) --------------- # --------------- Scene 6: Check Domain blocker (18-21s) ---------------
log "Scene 5: Check Domain — blocked tracker..." log "Scene 6: Check Domain — blocked tracker..."
# Scroll down to blocking panel # Scroll down to blocking panel
run_js " run_js "
var blockPanel = document.getElementById('blockingPanel'); var blockPanel = document.getElementById('blockingPanel');
@@ -273,8 +282,8 @@ sleep 0.3
run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();" run_js "document.querySelector('#checkDomainInput').closest('form').querySelector('.btn').click();"
sleep 2 sleep 2
# --------------- Scene 6: Terminal-style dig overlay (17-20s) --------------- # --------------- Scene 7: Terminal-style dig overlay (21-24s) ---------------
log "Scene 6: dig proof overlay..." log "Scene 7: dig proof overlay..."
DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1) DIG_RESULT=$(dig @127.0.0.1 peekm.numa +short 2>/dev/null | head -1)
run_js " run_js "
var overlay = document.createElement('div'); var overlay = document.createElement('div');

View File

@@ -298,7 +298,5 @@ $body$
<a href="/blog/">Blog</a> <a href="/blog/">Blog</a>
</footer> </footer>
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body> </body>
</html> </html>

View File

@@ -197,7 +197,5 @@ body::before {
<a href="/">Home</a> <a href="/">Home</a>
</footer> </footer>
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body> </body>
</html> </html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

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 */
@@ -554,6 +562,20 @@ body {
<div class="tagline">DNS that governs itself</div> <div class="tagline">DNS that governs itself</div>
</div> </div>
<div style="display:flex;align-items:center;gap:1.2rem;"> <div style="display:flex;align-items:center;gap:1.2rem;">
<div id="phoneSetup" style="position:relative;display:none;">
<button class="btn" onclick="togglePhoneSetup()" style="background:var(--bg-surface);color:var(--text-secondary);font-family:var(--font-mono);font-size:0.7rem;padding:0.35rem 0.6rem;border:1px solid var(--border);" title="Set up phone">Phone Setup</button>
<div id="phoneSetupPopover" style="display:none;position:absolute;top:calc(100% + 8px);right:0;z-index:100;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:1.2rem;width:260px;box-shadow:0 4px 20px rgba(0,0,0,0.08);">
<div style="font-size:0.7rem;font-weight:600;text-transform:uppercase;letter-spacing:0.1em;color:var(--text-secondary);margin-bottom:0.8rem;">Phone Setup</div>
<div id="qrContainer" style="display:flex;justify-content:center;margin-bottom:0.8rem;"></div>
<div id="phoneSetupLink" style="display:none;text-align:center;margin-bottom:0.8rem;"></div>
<div style="font-family:var(--font-mono);font-size:0.62rem;color:var(--text-dim);line-height:1.6;">
1. Scan QR &rarr; allow download<br>
2. Settings &rarr; Profile Downloaded &rarr; Install<br>
3. Settings &rarr; General &rarr; About &rarr;<br>
&nbsp;&nbsp;&nbsp;Certificate Trust Settings &rarr; toggle ON
</div>
</div>
</div>
<button class="btn" id="pauseBtn" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button> <button class="btn" id="pauseBtn" style="background:var(--amber);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;">Pause 5m</button>
<button class="btn" id="toggleBtn" onclick="toggleBlocking()" style="background:var(--rose);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;"></button> <button class="btn" id="toggleBtn" onclick="toggleBlocking()" style="background:var(--rose);color:white;font-family:var(--font-mono);font-size:0.7rem;display:none;"></button>
<div class="status-badge"> <div class="status-badge">
@@ -608,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 -->
@@ -629,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>
@@ -640,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>
@@ -788,6 +829,34 @@ function formatTime(epoch) {
return d.toLocaleTimeString([], { hour12: false }); return d.toLocaleTimeString([], { hour12: false });
} }
let mobilePort = 8765;
function togglePhoneSetup() {
const pop = document.getElementById('phoneSetupPopover');
const isOpen = pop.style.display !== 'none';
pop.style.display = isOpen ? 'none' : 'block';
if (!isOpen) {
if (window.innerWidth <= 700) {
document.getElementById('qrContainer').style.display = 'none';
const linkEl = document.getElementById('phoneSetupLink');
const host = window.location.hostname;
linkEl.style.display = 'block';
linkEl.innerHTML = `<a href="http://${host}:${mobilePort}/mobileconfig" style="display:inline-block;padding:0.5rem 1rem;background:var(--amber);color:white;border-radius:6px;text-decoration:none;font-family:var(--font-mono);font-size:0.75rem;">Install Profile</a>`;
} else {
fetch(API + '/qr').then(r => r.text()).then(svg => {
document.getElementById('qrContainer').innerHTML = svg;
}).catch(() => {
document.getElementById('qrContainer').innerHTML = '<div class="empty-state">Could not load QR</div>';
});
}
}
}
document.addEventListener('click', (e) => {
const setup = document.getElementById('phoneSetup');
if (setup && !setup.contains(e.target)) {
document.getElementById('phoneSetupPopover').style.display = 'none';
}
});
function shortSrc(addr) { function shortSrc(addr) {
if (!addr) return ''; if (!addr) return '';
const ip = addr.replace(/:\d+$/, ''); const ip = addr.replace(/:\d+$/, '');
@@ -865,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' },
@@ -876,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) {
@@ -900,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) {
@@ -908,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 =
@@ -925,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>`;
@@ -1058,6 +1156,14 @@ async function refresh() {
} }
} }
const phoneSetupEl = document.getElementById('phoneSetup');
if (stats.mobile && stats.mobile.enabled) {
phoneSetupEl.style.display = '';
mobilePort = stats.mobile.port;
} else {
phoneSetupEl.style.display = 'none';
}
document.getElementById('overrideCount').textContent = stats.overrides.active; document.getElementById('overrideCount').textContent = stats.overrides.active;
document.getElementById('blockedCount').textContent = formatNumber(q.blocked); document.getElementById('blockedCount').textContent = formatNumber(q.blocked);
const bl = stats.blocking; const bl = stats.blocking;
@@ -1091,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;
@@ -1107,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

@@ -1769,7 +1769,5 @@ const observer = new IntersectionObserver((entries) => {
document.querySelectorAll('.reveal').forEach(el => observer.observe(el)); document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
</script> </script>
<script data-goatcounter="https://razvandimescu.goatcounter.com/count"
async src="//gc.zgo.at/count.js"></script>
</body> </body>
</html> </html>

View File

@@ -57,6 +57,7 @@ pub fn router(ctx: Arc<ServerCtx>) -> Router {
.route("/services/{name}/routes", post(add_route)) .route("/services/{name}/routes", post(add_route))
.route("/services/{name}/routes", delete(remove_route)) .route("/services/{name}/routes", delete(remove_route))
.route("/ca.pem", get(serve_ca)) .route("/ca.pem", get(serve_ca))
.route("/qr", get(serve_qr))
.route("/fonts/fonts.css", get(serve_fonts_css)) .route("/fonts/fonts.css", get(serve_fonts_css))
.route( .route(
"/fonts/dm-sans-latin.woff2", "/fonts/dm-sans-latin.woff2",
@@ -151,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,
@@ -166,13 +168,29 @@ 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,
lan: LanStatsResponse, lan: LanStatsResponse,
mobile: MobileStatsResponse,
memory: MemoryStats, memory: MemoryStats,
} }
#[derive(Serialize)]
struct TransportStats {
udp: u64,
tcp: u64,
dot: u64,
doh: u64,
}
#[derive(Serialize)]
struct MobileStatsResponse {
enabled: bool,
port: u16,
}
#[derive(Serialize)] #[derive(Serialize)]
struct LanStatsResponse { struct LanStatsResponse {
enabled: bool, enabled: bool,
@@ -403,9 +421,12 @@ async fn diagnose(
} }
// Check upstream (async, no locks held) // Check upstream (async, no locks held)
let upstream = ctx.upstream.lock().unwrap().clone(); let upstream = ctx.upstream_pool.lock().unwrap().preferred().cloned();
let (upstream_matched, upstream_detail) = let (upstream_matched, upstream_detail) = if let Some(ref u) = upstream {
forward_query_for_diagnose(&domain_lower, &upstream, ctx.timeout).await; forward_query_for_diagnose(&domain_lower, u, ctx.timeout).await
} else {
(false, "no upstream configured".to_string())
};
steps.push(DiagnoseStep { steps.push(DiagnoseStep {
source: "upstream".to_string(), source: "upstream".to_string(),
matched: upstream_matched, matched: upstream_matched,
@@ -472,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(),
@@ -512,7 +534,7 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive { let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
"recursive (root hints)".to_string() "recursive (root hints)".to_string()
} else { } else {
ctx.upstream.lock().unwrap().to_string() ctx.upstream_pool.lock().unwrap().label()
}; };
Json(StatsResponse { Json(StatsResponse {
@@ -534,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,
@@ -551,6 +579,10 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
enabled: ctx.lan_enabled, enabled: ctx.lan_enabled,
peers: ctx.lan_peers.lock().unwrap().list().len(), peers: ctx.lan_peers.lock().unwrap().list().len(),
}, },
mobile: MobileStatsResponse {
enabled: ctx.mobile_enabled,
port: ctx.mobile_port,
},
memory: MemoryStats { memory: MemoryStats {
cache_bytes, cache_bytes,
blocklist_bytes, blocklist_bytes,
@@ -931,6 +963,28 @@ pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResp
)) ))
} }
async fn serve_qr(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
if !ctx.mobile_enabled {
return Err(StatusCode::NOT_FOUND);
}
let lan_ip = *ctx.lan_ip.lock().unwrap();
let url = format!("http://{}:{}/mobileconfig", lan_ip, ctx.mobile_port);
let code = qrcode::QrCode::new(&url).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let svg = code
.render::<qrcode::render::svg::Color>()
.min_dimensions(180, 180)
.dark_color(qrcode::render::svg::Color("#2c2418"))
.light_color(qrcode::render::svg::Color("#faf7f2"))
.build();
Ok((
[
(header::CONTENT_TYPE, "image/svg+xml"),
(header::CACHE_CONTROL, "no-store"),
],
svg,
))
}
async fn serve_fonts_css() -> impl IntoResponse { async fn serve_fonts_css() -> impl IntoResponse {
( (
[ [
@@ -975,6 +1029,7 @@ mod tests {
socket, socket,
zone_map: std::collections::HashMap::new(), zone_map: std::collections::HashMap::new(),
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
refreshing: Mutex::new(std::collections::HashSet::new()),
stats: Mutex::new(crate::stats::ServerStats::new()), stats: Mutex::new(crate::stats::ServerStats::new()),
overrides: RwLock::new(crate::override_store::OverrideStore::new()), overrides: RwLock::new(crate::override_store::OverrideStore::new()),
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()), blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
@@ -982,13 +1037,17 @@ mod tests {
services: Mutex::new(crate::service_store::ServiceStore::new()), services: Mutex::new(crate::service_store::ServiceStore::new()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)), lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
forwarding_rules: Vec::new(), forwarding_rules: Vec::new(),
upstream: Mutex::new(crate::forward::Upstream::Udp( upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
vec![crate::forward::Upstream::Udp(
"127.0.0.1:53".parse().unwrap(), "127.0.0.1:53".parse().unwrap(),
)],
vec![],
)), )),
upstream_auto: false, upstream_auto: false,
upstream_port: 53, upstream_port: 53,
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
timeout: std::time::Duration::from_secs(3), timeout: std::time::Duration::from_secs(3),
hedge_delay: std::time::Duration::ZERO,
proxy_tld: "numa".to_string(), proxy_tld: "numa".to_string(),
proxy_tld_suffix: ".numa".to_string(), proxy_tld_suffix: ".numa".to_string(),
lan_enabled: false, lan_enabled: false,
@@ -1005,6 +1064,8 @@ mod tests {
dnssec_strict: false, dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(), health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None, ca_pem: None,
mobile_enabled: false,
mobile_port: 8765,
}) })
} }

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,54 +72,60 @@ impl DnsCache {
} }
} }
/// Read-only lookup — expired entries are left in place (cleaned up on insert). /// Look up cached wire bytes, patching ID and TTLs in the returned copy.
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> { /// Implements serve-stale (RFC 8767): expired entries within STALE_WINDOW
self.lookup_with_status(domain, qtype).map(|(pkt, _)| pkt) /// are returned with TTL=1 and `stale=true` so callers can revalidate.
} pub fn lookup_wire(
pub fn lookup_with_status(
&self, &self,
domain: &str, domain: &str,
qtype: QueryType, qtype: QueryType,
) -> Option<(DnsPacket, DnssecStatus)> { new_id: u16,
) -> Option<(Vec<u8>, DnssecStatus, Freshness)> {
let type_map = self.entries.get(domain)?; let type_map = self.entries.get(domain)?;
let entry = type_map.get(&qtype)?; let entry = type_map.get(&qtype)?;
let elapsed = entry.inserted_at.elapsed(); let elapsed = entry.inserted_at.elapsed();
if elapsed >= entry.ttl { 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; 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))
} }
let remaining_secs = (entry.ttl - elapsed).as_secs() as u32; pub fn insert_wire(
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 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, &mut self,
domain: &str, domain: &str,
qtype: QueryType, qtype: QueryType,
packet: &DnsPacket, wire: &[u8],
dnssec_status: DnssecStatus, 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 { if self.entry_count >= self.max_entries {
self.evict_expired(); self.evict_expired();
if self.entry_count >= self.max_entries { if self.entry_count >= self.max_entries {
return; self.evict_stalest();
} }
} }
let min_ttl = extract_min_ttl(&packet.answers) let min_ttl = crate::wire::min_ttl_from_wire(wire, &meta)
.unwrap_or(self.min_ttl) .unwrap_or(self.min_ttl)
.clamp(self.min_ttl, self.max_ttl); .clamp(self.min_ttl, self.max_ttl);
@@ -117,7 +142,8 @@ impl DnsCache {
type_map.insert( type_map.insert(
qtype, qtype,
CacheEntry { CacheEntry {
packet: packet.clone(), wire: wire.to_vec(),
meta,
inserted_at: Instant::now(), inserted_at: Instant::now(),
ttl: Duration::from_secs(min_ttl as u64), ttl: Duration::from_secs(min_ttl as u64),
dnssec_status, dnssec_status,
@@ -125,6 +151,64 @@ impl DnsCache {
); );
} }
/// Read-only lookup — expired entries are left in place (cleaned up on insert).
pub fn lookup(&self, domain: &str, qtype: QueryType) -> Option<DnsPacket> {
self.lookup_with_status(domain, qtype)
.map(|(pkt, _, _)| pkt)
}
pub fn lookup_with_status(
&self,
domain: &str,
qtype: QueryType,
) -> Option<(DnsPacket, DnssecStatus, Freshness)> {
let (wire, status, freshness) = self.lookup_wire(domain, qtype, 0)?;
let mut buf = BytePacketBuffer::from_bytes(&wire);
let pkt = DnsPacket::from_buffer(&mut buf).ok()?;
Some((pkt, status, freshness))
}
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,
) {
let mut buf = BytePacketBuffer::new();
if packet.write(&mut buf).is_err() {
return;
}
self.insert_wire(domain, qtype, buf.filled(), dnssec_status);
}
pub fn ttl_remaining(&self, domain: &str, qtype: QueryType) -> Option<(u32, u32)> {
let type_map = self.entries.get(domain)?;
let entry = type_map.get(&qtype)?;
let elapsed = entry.inserted_at.elapsed();
if elapsed >= entry.ttl {
return None;
}
let total = entry.ttl.as_secs() as u32;
let remaining = (entry.ttl - elapsed).as_secs() as u32;
Some((remaining, total))
}
pub fn needs_warm(&self, domain: &str) -> bool {
for qtype in [QueryType::A, QueryType::AAAA] {
match self.ttl_remaining(domain, qtype) {
None => return true,
Some((remaining, total)) if remaining < total / 4 => return true,
_ => {}
}
}
false
}
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.entry_count self.entry_count
} }
@@ -156,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
@@ -197,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 {
@@ -205,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() {
@@ -233,4 +337,66 @@ mod tests {
cache.insert("example.com", QueryType::A, &pkt); cache.insert("example.com", QueryType::A, &pkt);
assert!(cache.heap_bytes() > empty); assert!(cache.heap_bytes() > empty);
} }
#[test]
fn ttl_remaining_returns_values_for_fresh_entry() {
let mut cache = DnsCache::new(100, 60, 3600);
let mut pkt = DnsPacket::new();
pkt.answers.push(DnsRecord::A {
domain: "example.com".into(),
addr: "1.2.3.4".parse().unwrap(),
ttl: 300,
});
cache.insert("example.com", QueryType::A, &pkt);
let (remaining, total) = cache.ttl_remaining("example.com", QueryType::A).unwrap();
assert_eq!(total, 300);
assert!(remaining <= 300);
assert!(remaining > 0);
}
#[test]
fn ttl_remaining_none_for_missing() {
let cache = DnsCache::new(100, 1, 3600);
assert!(cache.ttl_remaining("missing.com", QueryType::A).is_none());
}
#[test]
fn needs_warm_true_when_missing() {
let cache = DnsCache::new(100, 1, 3600);
assert!(cache.needs_warm("missing.com"));
}
#[test]
fn needs_warm_false_when_fresh() {
let mut cache = DnsCache::new(100, 1, 3600);
let mut pkt_a = DnsPacket::new();
pkt_a.answers.push(DnsRecord::A {
domain: "example.com".into(),
addr: "1.2.3.4".parse().unwrap(),
ttl: 300,
});
let mut pkt_aaaa = DnsPacket::new();
pkt_aaaa.answers.push(DnsRecord::AAAA {
domain: "example.com".into(),
addr: "::1".parse().unwrap(),
ttl: 300,
});
cache.insert("example.com", QueryType::A, &pkt_a);
cache.insert("example.com", QueryType::AAAA, &pkt_aaaa);
assert!(!cache.needs_warm("example.com"));
}
#[test]
fn needs_warm_true_when_only_a_cached() {
let mut cache = DnsCache::new(100, 1, 3600);
let mut pkt = DnsPacket::new();
pkt.answers.push(DnsRecord::A {
domain: "example.com".into(),
addr: "1.2.3.4".parse().unwrap(),
ttl: 300,
});
cache.insert("example.com", QueryType::A, &pkt);
// AAAA missing → needs warm
assert!(cache.needs_warm("example.com"));
}
} }

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 addr = crate::forward::parse_upstream_addr(&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(), addr))
.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)]
@@ -97,12 +130,16 @@ impl UpstreamMode {
pub struct UpstreamConfig { pub struct UpstreamConfig {
#[serde(default)] #[serde(default)]
pub mode: UpstreamMode, pub mode: UpstreamMode,
#[serde(default = "default_upstream_addr")] #[serde(default, deserialize_with = "string_or_vec")]
pub address: String, pub address: Vec<String>,
#[serde(default = "default_upstream_port")] #[serde(default = "default_upstream_port")]
pub port: u16, pub port: u16,
#[serde(default)]
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")]
@@ -115,9 +152,11 @@ impl Default for UpstreamConfig {
fn default() -> Self { fn default() -> Self {
UpstreamConfig { UpstreamConfig {
mode: UpstreamMode::default(), mode: UpstreamMode::default(),
address: default_upstream_addr(), address: Vec::new(),
port: default_upstream_port(), port: default_upstream_port(),
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(),
@@ -125,6 +164,33 @@ impl Default for UpstreamConfig {
} }
} }
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = Vec<String>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("string or array of strings")
}
fn visit_str<E: serde::de::Error>(self, v: &str) -> std::result::Result<Self::Value, E> {
Ok(vec![v.to_string()])
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut seq: A,
) -> std::result::Result<Self::Value, A::Error> {
let mut v = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
v.push(s);
}
Ok(v)
}
}
deserializer.deserialize_any(Visitor)
}
fn default_true() -> bool { fn default_true() -> bool {
true true
} }
@@ -202,15 +268,15 @@ fn default_root_hints() -> Vec<String> {
] ]
} }
fn default_upstream_addr() -> String {
String::new() // empty = auto-detect from system resolver
}
fn default_upstream_port() -> u16 { fn default_upstream_port() -> u16 {
53 53
} }
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 {
@@ -220,6 +286,8 @@ pub struct CacheConfig {
pub min_ttl: u32, pub min_ttl: u32,
#[serde(default = "default_max_ttl")] #[serde(default = "default_max_ttl")]
pub max_ttl: u32, pub max_ttl: u32,
#[serde(default)]
pub warm: Vec<String>,
} }
impl Default for CacheConfig { impl Default for CacheConfig {
@@ -228,12 +296,13 @@ impl Default for CacheConfig {
max_entries: default_max_entries(), max_entries: default_max_entries(),
min_ttl: default_min_ttl(), min_ttl: default_min_ttl(),
max_ttl: default_max_ttl(), max_ttl: default_max_ttl(),
warm: Vec::new(),
} }
} }
} }
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
@@ -381,7 +450,7 @@ pub struct DnssecConfig {
#[derive(Deserialize, Clone)] #[derive(Deserialize, Clone)]
pub struct DotConfig { pub struct DotConfig {
#[serde(default)] #[serde(default = "default_dot_enabled")]
pub enabled: bool, pub enabled: bool,
#[serde(default = "default_dot_port")] #[serde(default = "default_dot_port")]
pub port: u16, pub port: u16,
@@ -398,7 +467,7 @@ pub struct DotConfig {
impl Default for DotConfig { impl Default for DotConfig {
fn default() -> Self { fn default() -> Self {
DotConfig { DotConfig {
enabled: false, enabled: default_dot_enabled(),
port: default_dot_port(), port: default_dot_port(),
bind_addr: default_dot_bind_addr(), bind_addr: default_dot_bind_addr(),
cert_path: None, cert_path: None,
@@ -407,6 +476,9 @@ impl Default for DotConfig {
} }
} }
fn default_dot_enabled() -> bool {
true
}
fn default_dot_port() -> u16 { fn default_dot_port() -> u16 {
853 853
} }
@@ -525,6 +597,184 @@ mod tests {
assert!(config.services[0].routes[0].strip); assert!(config.services[0].routes[0].strip);
assert!(!config.services[0].routes[1].strip); // default false assert!(!config.services[0].routes[1].strip); // default false
} }
#[test]
fn address_string_parses_to_vec() {
let config: Config = toml::from_str("[upstream]\naddress = \"1.2.3.4\"").unwrap();
assert_eq!(config.upstream.address, vec!["1.2.3.4"]);
}
#[test]
fn address_array_parses() {
let config: Config =
toml::from_str("[upstream]\naddress = [\"1.2.3.4\", \"5.6.7.8:5353\"]").unwrap();
assert_eq!(config.upstream.address, vec!["1.2.3.4", "5.6.7.8:5353"]);
}
#[test]
fn fallback_parses() {
let config: Config =
toml::from_str("[upstream]\nfallback = [\"8.8.8.8\", \"1.1.1.1\"]").unwrap();
assert_eq!(config.upstream.fallback, vec!["8.8.8.8", "1.1.1.1"]);
}
#[test]
fn empty_address_gives_empty_vec() {
let config: Config = toml::from_str("").unwrap();
assert!(config.upstream.address.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_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_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(),
"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(),
"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 {
@@ -552,6 +802,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
}; };
@@ -572,11 +829,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, Upstream}; 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>,
@@ -42,11 +44,12 @@ pub struct ServerCtx {
pub services: Mutex<ServiceStore>, pub services: Mutex<ServiceStore>,
pub lan_peers: Mutex<PeerStore>, pub lan_peers: Mutex<PeerStore>,
pub forwarding_rules: Vec<ForwardingRule>, pub forwarding_rules: Vec<ForwardingRule>,
pub upstream: Mutex<Upstream>, pub upstream_pool: Mutex<UpstreamPool>,
pub upstream_auto: bool, pub upstream_auto: bool,
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,
@@ -70,6 +73,8 @@ pub struct ServerCtx {
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig` /// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
/// handlers to avoid per-request disk I/O on the hot path. /// handlers to avoid per-request disk I/O on the hot path.
pub ca_pem: Option<String>, pub ca_pem: Option<String>,
pub mobile_enabled: bool,
pub mobile_port: u16,
} }
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, /// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist,
@@ -79,8 +84,10 @@ 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>,
transport: Transport,
) -> crate::Result<BytePacketBuffer> { ) -> crate::Result<BytePacketBuffer> {
let start = Instant::now(); let start = Instant::now();
@@ -108,6 +115,10 @@ pub async fn resolve_query(
300, 300,
)); ));
(resp, QueryPath::Local, DnssecStatus::Indeterminate) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers = records.clone();
(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 // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
let resp = special_use_response(&query, &qname, qtype); let resp = special_use_response(&query, &qname, qtype);
@@ -156,13 +167,20 @@ pub async fn resolve_query(
60, 60,
)); ));
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate) (resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers = records.clone();
(resp, QueryPath::Local, 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 {
@@ -175,11 +193,8 @@ pub async fn resolve_query(
// 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); let upstream = Upstream::Udp(fwd_addr);
match forward_query(&query, &upstream, ctx.timeout).await { match forward_and_cache(raw_wire, &upstream, ctx, &qname, qtype).await {
Ok(resp) => { Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
}
Err(e) => { Err(e) => {
error!( error!(
"{} | {:?} {} | FORWARD ERROR | {}", "{} | {:?} {} | FORWARD ERROR | {}",
@@ -218,16 +233,27 @@ pub async fn resolve_query(
} }
(resp, path, DnssecStatus::Indeterminate) (resp, path, DnssecStatus::Indeterminate)
} else { } else {
let upstream = let pool = ctx.upstream_pool.lock().unwrap().clone();
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { match forward_with_failover_raw(
Some(addr) => Upstream::Udp(addr), raw_wire,
None => ctx.upstream.lock().unwrap().clone(), &pool,
}; &ctx.srtt,
match forward_query(&query, &upstream, ctx.timeout).await { ctx.timeout,
Ok(resp) => { ctx.hedge_delay,
ctx.cache.write().unwrap().insert(&qname, qtype, &resp); )
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) .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 | {}",
@@ -329,7 +355,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();
} }
@@ -341,6 +367,7 @@ 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,
@@ -349,11 +376,78 @@ pub async fn resolve_query(
Ok(resp_buffer) Ok(resp_buffer)
} }
/// Handle a DNS query received over UDP. Thin wrapper around resolve_query. 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)
}
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,
@@ -362,7 +456,7 @@ 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?;
} }

224
src/doh.rs Normal file
View File

@@ -0,0 +1,224 @@
use std::net::SocketAddr;
use axum::body::Bytes;
use axum::extract::{Request, State};
use axum::response::{IntoResponse, Response};
use hyper::StatusCode;
use log::warn;
use crate::buffer::BytePacketBuffer;
use crate::ctx::{resolve_query, ServerCtx};
use crate::header::ResultCode;
use crate::packet::DnsPacket;
use crate::stats::Transport;
const MAX_DNS_MSG: usize = 4096;
const DOH_CONTENT_TYPE: &str = "application/dns-message";
pub async fn doh_post(State(state): State<super::proxy::DohState>, req: Request) -> Response {
let host = super::proxy::extract_host(&req);
if !is_doh_host(host.as_deref(), &state.ctx.proxy_tld) {
return StatusCode::NOT_FOUND.into_response();
}
let content_type = req
.headers()
.get(hyper::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if !content_type.starts_with(DOH_CONTENT_TYPE) {
return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response();
}
let body = match axum::body::to_bytes(req.into_body(), MAX_DNS_MSG).await {
Ok(b) => b,
Err(_) => {
return (StatusCode::PAYLOAD_TOO_LARGE, "body exceeds 4096 bytes").into_response()
}
};
if body.is_empty() {
return (StatusCode::BAD_REQUEST, "empty body").into_response();
}
let src = state
.remote_addr
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0)));
resolve_doh(&body, src, &state.ctx).await
}
fn is_doh_host(host: Option<&str>, tld: &str) -> bool {
let h = match host {
Some(h) => h,
None => return false,
};
let base = strip_port(h).unwrap_or(h);
is_loopback_host(base) || is_tld_match(base, 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;
}
port.bytes().all(|b| b.is_ascii_digit()).then_some(base)
}
}
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 query = match DnsPacket::from_buffer(&mut buffer) {
Ok(q) => q,
Err(e) => {
warn!("DoH: parse error from {}: {}", src, e);
let query_id = u16::from_be_bytes([
dns_bytes.first().copied().unwrap_or(0),
dns_bytes.get(1).copied().unwrap_or(0),
]);
let mut resp = DnsPacket::new();
resp.header.id = query_id;
resp.header.response = true;
resp.header.rescode = ResultCode::FORMERR;
return serialize_response(&resp);
}
};
let query_id = query.header.id;
let query_rd = query.header.recursion_desired;
let questions = query.questions.clone();
match resolve_query(query, dns_bytes, src, ctx, Transport::Doh).await {
Ok(resp_buffer) => {
let min_ttl = extract_min_ttl(resp_buffer.filled());
dns_response(resp_buffer.filled(), min_ttl)
}
Err(e) => {
warn!("DoH: resolve error for {}: {}", src, e);
let mut resp = DnsPacket::new();
resp.header.id = query_id;
resp.header.response = true;
resp.header.recursion_desired = query_rd;
resp.header.recursion_available = true;
resp.header.rescode = ResultCode::SERVFAIL;
resp.questions = questions;
serialize_response(&resp)
}
}
}
fn extract_min_ttl(wire: &[u8]) -> u32 {
crate::wire::scan_ttl_offsets(wire)
.ok()
.and_then(|meta| crate::wire::min_ttl_from_wire(wire, &meta))
.unwrap_or(0)
}
fn dns_response(wire: &[u8], min_ttl: u32) -> Response {
(
StatusCode::OK,
[
(hyper::header::CONTENT_TYPE, DOH_CONTENT_TYPE),
(
hyper::header::CACHE_CONTROL,
&format!("max-age={}", min_ttl),
),
],
Bytes::copy_from_slice(wire),
)
.into_response()
}
fn serialize_response(pkt: &DnsPacket) -> Response {
let mut buf = BytePacketBuffer::new();
match pkt.write(&mut buf) {
Ok(_) => dns_response(buf.filled(), 0),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::BytePacketBuffer;
use crate::header::ResultCode;
use crate::packet::DnsPacket;
use crate::record::DnsRecord;
#[test]
fn is_doh_host_matches_tld() {
assert!(is_doh_host(Some("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(None, "numa"));
}
#[test]
fn extract_min_ttl_from_response() {
let mut pkt = DnsPacket::new();
pkt.header.response = true;
pkt.answers.push(DnsRecord::A {
domain: "example.com".to_string(),
addr: std::net::Ipv4Addr::new(1, 2, 3, 4),
ttl: 300,
});
pkt.answers.push(DnsRecord::A {
domain: "example.com".to_string(),
addr: std::net::Ipv4Addr::new(5, 6, 7, 8),
ttl: 60,
});
let mut buf = BytePacketBuffer::new();
pkt.write(&mut buf).unwrap();
assert_eq!(extract_min_ttl(buf.filled()), 60);
}
#[test]
fn extract_min_ttl_no_answers() {
let mut pkt = DnsPacket::new();
pkt.header.response = true;
let mut buf = BytePacketBuffer::new();
pkt.write(&mut buf).unwrap();
assert_eq!(extract_min_ttl(buf.filled()), 0);
}
#[test]
fn serialize_formerr_response() {
let mut pkt = DnsPacket::new();
pkt.header.id = 0xABCD;
pkt.header.response = true;
pkt.header.rescode = ResultCode::FORMERR;
let resp = serialize_response(&pkt);
assert_eq!(resp.status(), StatusCode::OK);
}
}

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,7 +202,15 @@ where
} }
}; };
match resolve_query(query.clone(), remote_addr, ctx).await { match resolve_query(
query.clone(),
&buffer.buf[..msg_len],
remote_addr,
ctx,
Transport::Dot,
)
.await
{
Ok(resp_buffer) => { Ok(resp_buffer) => {
if write_framed(&mut stream, resp_buffer.filled()) if write_framed(&mut stream, resp_buffer.filled())
.await .await
@@ -355,6 +365,7 @@ mod tests {
m m
}, },
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)), cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
refreshing: Mutex::new(std::collections::HashSet::new()),
stats: Mutex::new(crate::stats::ServerStats::new()), stats: Mutex::new(crate::stats::ServerStats::new()),
overrides: RwLock::new(crate::override_store::OverrideStore::new()), overrides: RwLock::new(crate::override_store::OverrideStore::new()),
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()), blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
@@ -362,11 +373,15 @@ mod tests {
services: Mutex::new(crate::service_store::ServiceStore::new()), services: Mutex::new(crate::service_store::ServiceStore::new()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)), lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
forwarding_rules: Vec::new(), forwarding_rules: Vec::new(),
upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)), upstream_pool: Mutex::new(crate::forward::UpstreamPool::new(
vec![crate::forward::Upstream::Udp(upstream_addr)],
vec![],
)),
upstream_auto: false, upstream_auto: false,
upstream_port: 53, upstream_port: 53,
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST), lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
timeout: Duration::from_millis(200), timeout: Duration::from_millis(200),
hedge_delay: Duration::ZERO,
proxy_tld: "numa".to_string(), proxy_tld: "numa".to_string(),
proxy_tld_suffix: ".numa".to_string(), proxy_tld_suffix: ".numa".to_string(),
lan_enabled: false, lan_enabled: false,
@@ -383,6 +398,8 @@ mod tests {
dnssec_strict: false, dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(), health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None, 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();

View File

@@ -1,12 +1,14 @@
use std::fmt; use std::fmt;
use std::net::SocketAddr; use std::net::{IpAddr, SocketAddr};
use std::time::Duration; use std::sync::RwLock;
use std::time::{Duration, Instant};
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio::time::timeout; use tokio::time::timeout;
use crate::buffer::BytePacketBuffer; use crate::buffer::BytePacketBuffer;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::srtt::SrttCache;
use crate::Result; use crate::Result;
#[derive(Clone)] #[derive(Clone)]
@@ -16,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 {
@@ -23,6 +30,7 @@ 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,
} }
} }
@@ -33,6 +41,118 @@ impl fmt::Display for Upstream {
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> {
// Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353"
if let Ok(addr) = s.parse::<SocketAddr>() {
return Ok(addr);
}
// Bare IP: "1.2.3.4" or "::1"
if let Ok(ip) = s.parse::<IpAddr>() {
return Ok(SocketAddr::new(ip, default_port));
}
Err(format!("invalid upstream address: {}", s))
}
pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
if s.starts_with("https://") {
let client = 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()
.unwrap_or_default();
return Ok(Upstream::Doh {
url: s.to_string(),
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)?;
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)]
pub struct UpstreamPool {
primary: Vec<Upstream>,
fallback: Vec<Upstream>,
}
impl UpstreamPool {
pub fn new(primary: Vec<Upstream>, fallback: Vec<Upstream>) -> Self {
Self { primary, fallback }
}
pub fn preferred(&self) -> Option<&Upstream> {
self.primary.first().or(self.fallback.first())
}
pub fn set_primary(&mut self, primary: Vec<Upstream>) {
self.primary = primary;
}
/// Update the primary upstream if `new_addr` (parsed with `port`) differs
/// from the current preferred upstream. Returns `true` if the pool changed.
pub fn maybe_update_primary(&mut self, new_addr: &str, port: u16) -> bool {
let Ok(new_sock) = format!("{}:{}", new_addr, port).parse::<SocketAddr>() else {
return false;
};
let new_upstream = Upstream::Udp(new_sock);
if self.preferred() == Some(&new_upstream) {
return false;
}
self.primary = vec![new_upstream];
true
}
pub fn label(&self) -> String {
match self.preferred() {
Some(u) => {
let total = self.primary.len() + self.fallback.len();
if total > 1 {
format!("{} (+{} more)", u, total - 1)
} else {
u.to_string()
}
}
None => "none".to_string(),
} }
} }
} }
@@ -42,10 +162,11 @@ pub async fn forward_query(
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(
@@ -53,24 +174,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)
} }
@@ -107,22 +214,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??
@@ -130,9 +424,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)]
@@ -271,4 +581,121 @@ mod tests {
let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await; let result = forward_query(&make_query(), &upstream, Duration::from_millis(100)).await;
assert!(result.is_err()); assert!(result.is_err());
} }
#[test]
fn parse_addr_ip_only() {
let addr = parse_upstream_addr("1.2.3.4", 53).unwrap();
assert_eq!(addr, "1.2.3.4:53".parse::<SocketAddr>().unwrap());
}
#[test]
fn parse_addr_ip_port() {
let addr = parse_upstream_addr("1.2.3.4:5353", 53).unwrap();
assert_eq!(addr, "1.2.3.4:5353".parse::<SocketAddr>().unwrap());
}
#[test]
fn parse_addr_ipv6_bracketed() {
let addr = parse_upstream_addr("[::1]:5553", 53).unwrap();
assert_eq!(addr, "[::1]:5553".parse::<SocketAddr>().unwrap());
}
#[test]
fn parse_addr_ipv6_bare() {
let addr = parse_upstream_addr("::1", 53).unwrap();
assert_eq!(addr, "[::1]:53".parse::<SocketAddr>().unwrap());
}
#[test]
fn pool_label_single() {
let pool = UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
assert_eq!(pool.label(), "1.2.3.4:53");
}
#[test]
fn pool_label_multi() {
let pool = UpstreamPool::new(
vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())],
vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())],
);
assert_eq!(pool.label(), "1.2.3.4:53 (+1 more)");
}
#[tokio::test]
async fn failover_tries_next_on_failure() {
// First upstream is unreachable, second responds
let query = make_query();
let response_bytes = to_wire(&make_response(&query));
let app = axum::Router::new().route(
"/dns-query",
axum::routing::post(move || {
let body = response_bytes.clone();
async move {
(
[(axum::http::header::CONTENT_TYPE, "application/dns-message")],
body,
)
}
}),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let good_addr = listener.local_addr().unwrap();
tokio::spawn(axum::serve(listener, app).into_future());
// Unreachable UDP upstream + working DoH upstream
let pool = UpstreamPool::new(
vec![
Upstream::Udp("127.0.0.1:1".parse().unwrap()), // will fail
Upstream::Doh {
url: format!("http://{}/dns-query", good_addr),
client: reqwest::Client::new(),
},
],
vec![],
);
let srtt = RwLock::new(SrttCache::new(true));
let wire = to_wire(&query);
let resp_wire = forward_with_failover_raw(
&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.answers.len(), 1);
}
#[test]
fn maybe_update_primary_swaps_when_different() {
let mut pool = UpstreamPool::new(
vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())],
vec![Upstream::Udp("8.8.8.8:53".parse().unwrap())],
);
assert!(pool.maybe_update_primary("5.6.7.8", 53));
assert_eq!(pool.preferred().unwrap().to_string(), "5.6.7.8:53");
}
#[test]
fn maybe_update_primary_noop_when_same() {
let mut pool =
UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
assert!(!pool.maybe_update_primary("1.2.3.4", 53));
}
#[test]
fn maybe_update_primary_rejects_invalid_addr() {
let mut pool =
UpstreamPool::new(vec![Upstream::Udp("1.2.3.4:53".parse().unwrap())], vec![]);
assert!(!pool.maybe_update_primary("not-an-ip", 53));
assert_eq!(pool.preferred().unwrap().to_string(), "1.2.3.4:53");
}
} }

View File

@@ -73,11 +73,15 @@ impl HealthMeta {
recursive_enabled: bool, recursive_enabled: bool,
mdns_enabled: bool, mdns_enabled: bool,
blocking_enabled: bool, blocking_enabled: bool,
doh_enabled: bool,
) -> Self { ) -> Self {
let ca_path = data_dir.join("ca.pem"); let ca_path = data_dir.join("ca.pem");
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path); let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
let mut features = Vec::new(); let mut features = Vec::new();
if doh_enabled {
features.push("doh".to_string());
}
if dot_enabled { if dot_enabled {
features.push("dot".to_string()); features.push("dot".to_string());
} }

View File

@@ -5,6 +5,7 @@ pub mod cache;
pub mod config; pub mod config;
pub mod ctx; pub mod ctx;
pub mod dnssec; pub mod dnssec;
pub mod doh;
pub mod dot; pub mod dot;
pub mod forward; pub mod forward;
pub mod header; pub mod header;
@@ -25,6 +26,7 @@ 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;
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>;
@@ -43,6 +45,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
@@ -162,4 +200,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

@@ -11,11 +11,11 @@ use numa::buffer::BytePacketBuffer;
use numa::cache::DnsCache; use numa::cache::DnsCache;
use numa::config::{build_zone_map, load_config, ConfigLoad}; use numa::config::{build_zone_map, load_config, ConfigLoad};
use numa::ctx::{handle_query, ServerCtx}; use numa::ctx::{handle_query, ServerCtx};
use numa::forward::Upstream; 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,
}; };
@@ -129,18 +129,18 @@ async fn main() -> numa::Result<()> {
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints); let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode { let recursive_pool = || {
let dummy = UpstreamPool::new(vec![Upstream::Udp("0.0.0.0:0".parse().unwrap())], vec![]);
(dummy, "recursive (root hints)".to_string())
};
let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode {
numa::config::UpstreamMode::Auto => { numa::config::UpstreamMode::Auto => {
info!("auto mode: probing recursive resolution..."); info!("auto mode: probing recursive resolution...");
if numa::recursive::probe_recursive(&root_hints).await { if numa::recursive::probe_recursive(&root_hints).await {
info!("recursive probe succeeded — self-sovereign mode"); info!("recursive probe succeeded — self-sovereign mode");
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap()); let (pool, label) = recursive_pool();
( (numa::config::UpstreamMode::Recursive, false, pool, label)
numa::config::UpstreamMode::Recursive,
false,
dummy,
"recursive (root hints)".to_string(),
)
} else { } else {
log::warn!("recursive probe failed — falling back to Quad9 DoH"); log::warn!("recursive probe failed — falling back to Quad9 DoH");
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
@@ -149,55 +149,45 @@ async fn main() -> numa::Result<()> {
.unwrap_or_default(); .unwrap_or_default();
let url = DOH_FALLBACK.to_string(); let url = DOH_FALLBACK.to_string();
let label = url.clone(); let label = url.clone();
( let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
numa::config::UpstreamMode::Forward, (numa::config::UpstreamMode::Forward, false, pool, label)
false,
Upstream::Doh { url, client },
label,
)
} }
} }
numa::config::UpstreamMode::Recursive => { numa::config::UpstreamMode::Recursive => {
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap()); let (pool, label) = recursive_pool();
( (numa::config::UpstreamMode::Recursive, false, pool, label)
numa::config::UpstreamMode::Recursive,
false,
dummy,
"recursive (root hints)".to_string(),
)
} }
numa::config::UpstreamMode::Forward => { numa::config::UpstreamMode::Forward => {
let upstream_addr = if config.upstream.address.is_empty() { let addrs = if config.upstream.address.is_empty() {
system_dns let detected = system_dns
.default_upstream .default_upstream
.or_else(numa::system_dns::detect_dhcp_dns) .or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| { .unwrap_or_else(|| {
info!("could not detect system DNS, falling back to Quad9 DoH"); info!("could not detect system DNS, falling back to Quad9 DoH");
DOH_FALLBACK.to_string() DOH_FALLBACK.to_string()
}) });
vec![detected]
} else { } else {
config.upstream.address.clone() config.upstream.address.clone()
}; };
let upstream: Upstream = if upstream_addr.starts_with("https://") { let primary: Vec<Upstream> = addrs
let client = reqwest::Client::builder() .iter()
.use_rustls_tls() .map(|s| parse_upstream(s, config.upstream.port))
.build() .collect::<numa::Result<Vec<_>>>()?;
.unwrap_or_default(); let fallback: Vec<Upstream> = config
Upstream::Doh { .upstream
url: upstream_addr, .fallback
client, .iter()
} .map(|s| parse_upstream(s, config.upstream.port))
} else { .collect::<numa::Result<Vec<_>>>()?;
let addr: SocketAddr =
format!("{}:{}", upstream_addr, config.upstream.port).parse()?; let pool = UpstreamPool::new(primary, fallback);
Upstream::Udp(addr) let label = pool.label();
};
let label = upstream.to_string();
( (
numa::config::UpstreamMode::Forward, numa::config::UpstreamMode::Forward,
config.upstream.address.is_empty(), config.upstream.address.is_empty(),
upstream, pool,
label, label,
) )
} }
@@ -220,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.
@@ -253,6 +249,7 @@ async fn main() -> numa::Result<()> {
None None
}; };
let doh_enabled = initial_tls.is_some();
let health_meta = numa::health::HealthMeta::build( let health_meta = numa::health::HealthMeta::build(
&resolved_data_dir, &resolved_data_dir,
config.dot.enabled, config.dot.enabled,
@@ -262,6 +259,7 @@ async fn main() -> numa::Result<()> {
resolved_mode == numa::config::UpstreamMode::Recursive, resolved_mode == numa::config::UpstreamMode::Recursive,
config.lan.enabled, config.lan.enabled,
config.blocking.enabled, config.blocking.enabled,
doh_enabled,
); );
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok(); let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
@@ -287,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),
@@ -294,11 +293,12 @@ async fn main() -> numa::Result<()> {
services: Mutex::new(service_store), services: Mutex::new(service_store),
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
forwarding_rules, forwarding_rules,
upstream: Mutex::new(upstream), upstream_pool: Mutex::new(pool),
upstream_auto, upstream_auto,
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 {
@@ -319,6 +319,8 @@ async fn main() -> numa::Result<()> {
dnssec_strict: config.dnssec.strict, dnssec_strict: config.dnssec.strict,
health_meta, health_meta,
ca_pem, ca_pem,
mobile_enabled: config.mobile.enabled,
mobile_port: config.mobile.port,
}); });
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
@@ -410,6 +412,9 @@ async fn main() -> numa::Result<()> {
g, g,
&format!("max {} entries", config.cache.max_entries), &format!("max {} entries", config.cache.max_entries),
); );
if !config.cache.warm.is_empty() {
row("Warm", g, &format!("{} domains", config.cache.warm.len()));
}
row( row(
"Blocking", "Blocking",
g, g,
@@ -436,6 +441,13 @@ async fn main() -> numa::Result<()> {
if config.dot.enabled { if config.dot.enabled {
row("DoT", g, &format!("tls://:{}", config.dot.port)); row("DoT", g, &format!("tls://:{}", config.dot.port));
} }
if doh_enabled {
row(
"DoH",
g,
&format!("https://:{}/dns-query", config.proxy.tls_port),
);
}
if config.lan.enabled { if config.lan.enabled {
row("LAN", g, "mDNS (_numa._tcp.local)"); row("LAN", g, "mDNS (_numa._tcp.local)");
} }
@@ -492,6 +504,23 @@ async fn main() -> numa::Result<()> {
}); });
} }
// Spawn cache warming for user-configured domains
if !config.cache.warm.is_empty() {
let warm_ctx = Arc::clone(&ctx);
let warm_domains = config.cache.warm.clone();
tokio::spawn(async move {
cache_warm_loop(warm_ctx, warm_domains).await;
});
}
// 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()?;
@@ -571,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
@@ -579,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);
} }
}); });
@@ -611,29 +639,19 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
} }
} }
// Re-detect upstream every 30s or on LAN IP change (UDP only // Re-detect upstream every 30s or on LAN IP change (auto-detect only)
// DoH upstreams are explicitly configured via URL, not auto-detected) if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) {
if ctx.upstream_auto
&& matches!(*ctx.upstream.lock().unwrap(), Upstream::Udp(_))
&& (changed || tick.is_multiple_of(6))
{
let dns_info = numa::system_dns::discover_system_dns(); let dns_info = numa::system_dns::discover_system_dns();
let new_addr = dns_info let new_addr = dns_info
.default_upstream .default_upstream
.or_else(numa::system_dns::detect_dhcp_dns) .or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| QUAD9_IP.to_string()); .unwrap_or_else(|| QUAD9_IP.to_string());
if let Ok(new_sock) = let mut pool = ctx.upstream_pool.lock().unwrap();
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>() if pool.maybe_update_primary(&new_addr, ctx.upstream_port) {
{ info!("upstream changed → {}", pool.label());
let new_upstream = Upstream::Udp(new_sock);
let mut upstream = ctx.upstream.lock().unwrap();
if *upstream != new_upstream {
info!("upstream changed: {} → {}", upstream, new_upstream);
*upstream = new_upstream;
changed = true; changed = true;
} }
} }
}
// Flush stale LAN peers on any network change // Flush stale LAN peers on any network change
if changed { if changed {
@@ -738,3 +756,45 @@ async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
downloaded.len() downloaded.len()
); );
} }
async fn warm_domain(ctx: &ServerCtx, domain: &str) {
for qtype in [
numa::question::QueryType::A,
numa::question::QueryType::AAAA,
] {
numa::ctx::refresh_entry(ctx, domain, qtype).await;
}
}
async fn doh_keepalive_loop(ctx: Arc<ServerCtx>) {
let mut interval = tokio::time::interval(Duration::from_secs(25));
interval.tick().await; // skip first immediate tick
loop {
interval.tick().await;
let pool = ctx.upstream_pool.lock().unwrap().clone();
if let Some(upstream) = pool.preferred() {
numa::forward::keepalive_doh(upstream).await;
}
}
}
async fn cache_warm_loop(ctx: Arc<ServerCtx>, domains: Vec<String>) {
tokio::time::sleep(Duration::from_secs(2)).await;
for domain in &domains {
warm_domain(&ctx, domain).await;
}
info!("cache warm: {} domains resolved at startup", domains.len());
let mut interval = tokio::time::interval(Duration::from_secs(30));
interval.tick().await;
loop {
interval.tick().await;
for domain in &domains {
let refresh = ctx.cache.read().unwrap().needs_warm(domain);
if refresh {
warm_domain(&ctx, domain).await;
}
}
}
}

View File

@@ -144,8 +144,6 @@ fn build_ca_payload(ca_pem: &str) -> String {
} }
/// Render the `com.apple.dnsSettings.managed` payload dict for Full mode. /// Render the `com.apple.dnsSettings.managed` payload dict for Full mode.
/// Pins the device to Numa as its system resolver over DoT with
/// `ServerName = "numa.numa"` (must match the DoT cert SAN).
fn build_dns_payload(lan_ip: Ipv4Addr) -> String { fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
format!( format!(
r#" <dict> r#" <dict>
@@ -160,8 +158,21 @@ fn build_dns_payload(lan_ip: Ipv4Addr) -> String {
<key>ServerName</key> <key>ServerName</key>
<string>numa.numa</string> <string>numa.numa</string>
</dict> </dict>
<key>OnDemandRules</key>
<array>
<dict>
<key>Action</key>
<string>Connect</string>
<key>InterfaceTypeMatch</key>
<string>WiFi</string>
</dict>
<dict>
<key>Action</key>
<string>Disconnect</string>
</dict>
</array>
<key>PayloadDescription</key> <key>PayloadDescription</key>
<string>Routes all DNS queries through Numa over DNS-over-TLS</string> <string>Routes DNS queries through Numa over DoT when on Wi-Fi</string>
<key>PayloadDisplayName</key> <key>PayloadDisplayName</key>
<string>Numa DNS-over-TLS</string> <string>Numa DNS-over-TLS</string>
<key>PayloadIdentifier</key> <key>PayloadIdentifier</key>

View File

@@ -4,7 +4,7 @@ use std::sync::Arc;
use axum::body::Body; use axum::body::Body;
use axum::extract::{Request, State}; use axum::extract::{Request, State};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use axum::routing::any; use axum::routing::{any, post};
use axum::Router; use axum::Router;
use http_body_util::BodyExt; use http_body_util::BodyExt;
use hyper::StatusCode; use hyper::StatusCode;
@@ -18,6 +18,14 @@ use crate::ctx::ServerCtx;
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>; type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;
/// State passed to the DoH handler. Includes the remote address so
/// `resolve_query` can log the client IP.
#[derive(Clone)]
pub struct DohState {
pub ctx: Arc<ServerCtx>,
pub remote_addr: Option<std::net::SocketAddr>,
}
#[derive(Clone)] #[derive(Clone)]
struct ProxyState { struct ProxyState {
ctx: Arc<ServerCtx>, ctx: Arc<ServerCtx>,
@@ -74,9 +82,17 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
// Hold a separate Arc so we can access tls_config after ctx moves into ProxyState // Hold a separate Arc so we can access tls_config after ctx moves into ProxyState
let tls_holder = Arc::clone(&ctx); let tls_holder = Arc::clone(&ctx);
let state = ProxyState { ctx, client }; let proxy_state = ProxyState {
ctx: Arc::clone(&ctx),
client,
};
let app = Router::new().fallback(any(proxy_handler)).with_state(state); // DoH route (RFC 8484) served only on the TLS listener.
// DohState.remote_addr is set per-connection below.
let doh_state = DohState {
ctx,
remote_addr: None,
};
loop { loop {
let (tcp_stream, remote_addr) = match listener.accept().await { let (tcp_stream, remote_addr) = match listener.accept().await {
@@ -91,7 +107,17 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
// unwrap safe: guarded by is_none() check above // unwrap safe: guarded by is_none() check above
let acceptor = let acceptor =
TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load())); TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load()));
let app = app.clone();
let mut conn_doh_state = doh_state.clone();
conn_doh_state.remote_addr = Some(remote_addr);
let app = Router::new()
.route(
"/dns-query",
post(crate::doh::doh_post).with_state(conn_doh_state),
)
.fallback(any(proxy_handler))
.with_state(proxy_state.clone());
tokio::spawn(async move { tokio::spawn(async move {
let tls_stream = match acceptor.accept(tcp_stream).await { let tls_stream = match acceptor.accept(tcp_stream).await {
@@ -232,7 +258,7 @@ pre .str {{ color: #d48a5a }}
) )
} }
fn extract_host(req: &Request) -> Option<String> { pub fn extract_host(req: &Request) -> Option<String> {
req.headers() req.headers()
.get(hyper::header::HOST) .get(hyper::header::HOST)
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())

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,34 +1090,17 @@ 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" {
// Return referral — NS points back to ourselves (same IP, port 53 in glue
// won't work, but cache will have our address from root_hints)
let mut resp = DnsPacket::new();
resp.header.id = query.header.id;
resp.header.response = true;
resp.header.rescode = ResultCode::NOERROR;
resp.questions = query.questions.clone();
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); let mut resp = DnsPacket::response_from(query, ResultCode::NOERROR);
resp.header.authoritative_answer = true; resp.header.authoritative_answer = true;
resp.answers.push(DnsRecord::A { resp.answers.push(DnsRecord::A {
@@ -986,13 +1109,13 @@ mod tests {
ttl: 300, ttl: 300,
}); });
resp 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

@@ -25,6 +25,17 @@ pub struct ForwardingRule {
pub upstream: SocketAddr, pub upstream: SocketAddr,
} }
impl ForwardingRule {
pub fn new(suffix: String, upstream: SocketAddr) -> 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.
pub struct SystemDnsInfo { pub struct SystemDnsInfo {
pub default_upstream: Option<String>, pub default_upstream: Option<String>,
@@ -91,7 +102,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 +111,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 +232,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(), addr))
dot_suffix: format!(".{}", domain),
suffix: domain.to_string(),
upstream: addr,
})
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]

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:"
@@ -622,6 +632,54 @@ CONF
"10.0.0.1" \ "10.0.0.1" \
"$($KDIG +short dot-test.example A 2>/dev/null)" "$($KDIG +short dot-test.example A 2>/dev/null)"
echo ""
echo "=== DNS-over-HTTPS (RFC 8484) ==="
DOH_QUERY_FILE=/tmp/numa-doh-query.bin
DOH_RESP_FILE=/tmp/numa-doh-resp.bin
# Build DNS wire-format query for dot-test.example A
printf '\x00\x01\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x08dot-test\x07example\x00\x00\x01\x00\x01' > "$DOH_QUERY_FILE"
# POST valid DoH query
DOH_CODE=$(curl -sk -X POST \
--resolve "numa.numa:$PROXY_HTTPS_PORT:127.0.0.1" \
-H "Content-Type: application/dns-message" \
--data-binary @"$DOH_QUERY_FILE" \
--cacert "$CA" \
-o "$DOH_RESP_FILE" \
-w "%{http_code}" \
"https://numa.numa:$PROXY_HTTPS_PORT/dns-query")
check "DoH POST returns HTTP 200" "200" "$DOH_CODE"
# Check response contains IP 10.0.0.1 (hex: 0a000001)
DOH_HEX=$(xxd -p "$DOH_RESP_FILE" | tr -d '\n')
if echo "$DOH_HEX" | grep -q "0a000001"; then
check "DoH response resolves dot-test.example → 10.0.0.1" "found" "found"
else
check "DoH response resolves dot-test.example → 10.0.0.1" "0a000001" "$DOH_HEX"
fi
# Wrong Content-Type → 415
DOH_CT_CODE=$(curl -sk -X POST \
-H "Host: numa.numa" \
-H "Content-Type: text/plain" \
--data-binary @"$DOH_QUERY_FILE" \
-o /dev/null -w "%{http_code}" \
"https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query")
check "DoH wrong Content-Type → 415" "415" "$DOH_CT_CODE"
# Wrong host → 404 (DoH only serves numa.numa)
DOH_HOST_CODE=$(curl -sk -X POST \
-H "Host: foo.numa" \
-H "Content-Type: application/dns-message" \
--data-binary @"$DOH_QUERY_FILE" \
-o /dev/null -w "%{http_code}" \
"https://127.0.0.1:$PROXY_HTTPS_PORT/dns-query")
check "DoH wrong host → 404" "404" "$DOH_HOST_CODE"
rm -f "$DOH_QUERY_FILE" "$DOH_RESP_FILE"
echo "" echo ""
echo "=== Proxy TLS works with DoT enabled ===" echo "=== Proxy TLS works with DoT enabled ==="