63 Commits

Author SHA1 Message Date
Razvan Dimescu
edfccaa2b7 chore: update Cargo.lock for 0.7.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 08:22:32 +03:00
Razvan Dimescu
0c43240c01 chore: bump version to 0.7.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 08:16:26 +03:00
Razvan Dimescu
b615a56586 feat: SRTT-based nameserver selection (#19)
* feat: SRTT-based nameserver selection for recursive resolver

BIND-style Smoothed RTT (EWMA) tracking per NS IP address. The resolver
learns which nameservers respond fastest and prefers them, eliminating
cascading timeouts from slow/unreachable IPv6 servers.

- New src/srtt.rs: SrttCache with record_rtt, record_failure, sort_by_rtt
- EWMA formula: new = (old * 7 + sample) / 8, 5s failure penalty, 5min decay
- TCP penalty (+100ms) lets SRTT naturally deprioritize IPv6-over-TCP
- Enabled flag embedded in SrttCache (no-op when disabled)
- Batch eviction (64 entries) for O(1) amortized writes at capacity
- Configurable via [upstream] srtt = true/false (default: true)
- Benchmark script: scripts/benchmark.sh (full, cold, warm, compare-all)
- Benchmarks show 12x avg improvement, 0% queries >1s (was 58%)

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

* feat: show DNSSEC and SRTT status in dashboard + API

Add dnssec and srtt boolean fields to /stats API response.
Display on/off indicators in the dashboard footer.

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

* fix: apply SRTT decay before EWMA so recovered servers rehabilitate

Without decay-before-EWMA, a server penalized at 5000ms stayed near
that value even after recovery — the stale raw penalty was used as the
EWMA base instead of the decayed estimate. Extract decayed_srtt()
helper and call it in record_rtt() before the smoothing step.

Also restores removed "why" comments in send_query / resolve_recursive.

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

* docs: add install/upgrade instructions, smarter benchmark priming

README: document `numa install`, `numa service`, Homebrew upgrade,
and `make deploy` workflows. Benchmark: replace fixed `sleep 4` with
`wait_for_priming` that polls cache entry count for stability.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 23:22:31 +02:00
Razvan Dimescu
7056766a84 fix: return NXDOMAIN for .local queries instead of SERVFAIL (#18)
.local is reserved for mDNS (RFC 6762) and cannot be resolved by
upstream DNS servers. Add it to is_special_use_domain() so queries
like _grpc_config.localhost.local get an immediate NXDOMAIN instead
of timing out and returning SERVFAIL.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 22:42:33 +02:00
Razvan Dimescu
ebfc31d793 chore: bump version to 0.6.0
Recursive DNS resolution, full DNSSEC validation, TCP fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 04:12:28 +02:00
Razvan Dimescu
b6703b4315 feat: recursive DNS + DNSSEC + TCP fallback (#17)
* feat: recursive resolution + full DNSSEC validation

Numa becomes a true DNS resolver — resolves from root nameservers
with complete DNSSEC chain-of-trust verification.

Recursive resolution:
- Iterative RFC 1034 from configurable root hints (13 default)
- CNAME chasing (depth 8), referral following (depth 10)
- A+AAAA glue extraction, IPv6 nameserver support
- TLD priming: NS + DS + DNSKEY for 34 gTLDs + EU ccTLDs
- Config: mode = "recursive" in [upstream], root_hints, prime_tlds

DNSSEC (all 4 phases):
- EDNS0 OPT pseudo-record (DO bit, 1232 payload per DNS Flag Day 2020)
- DNSKEY, DS, RRSIG, NSEC, NSEC3 record types with wire read/write
- Signature verification via ring: RSA/SHA-256, ECDSA P-256, Ed25519
- Chain-of-trust: zone DNSKEY → parent DS → root KSK (key tag 20326)
- DNSKEY RRset self-signature verification (RRSIG(DNSKEY) by KSK)
- RRSIG expiration/inception time validation
- NSEC: NXDOMAIN gap proofs, NODATA type absence, wildcard denial
- NSEC3: SHA-1 iterated hashing, closest encloser proof, hash range
- Authority RRSIG verification for denial proofs
- Config: [dnssec] enabled/strict (default false, opt-in)
- AD bit on Secure, SERVFAIL on Bogus+strict
- DnssecStatus cached per entry, ValidationStats logging

Performance:
- TLD chain pre-warmed on startup (root DNSKEY + TLD DS/DNSKEY)
- Referral DS piggybacking from authority sections
- DNSKEY prefetch before validation loop
- Cold-cache validation: ~1 DNSKEY fetch (down from 5)
- Benchmarks: RSA 10.9µs, ECDSA 174ns, DS verify 257ns

Also:
- write_qname fix for root domain "." (was producing malformed queries)
- write_record_header() dedup, write_bytes() bulk writes
- DnsRecord::domain() + query_type() accessors
- UpstreamMode enum, DEFAULT_EDNS_PAYLOAD const
- Real glue TTL (was hardcoded 3600)
- DNSSEC restricted to recursive mode only

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

* feat: TCP fallback, query minimization, UDP auto-disable

Transport resilience for restrictive networks (ISPs blocking UDP:53):
- DNS-over-TCP fallback: UDP fail/truncation → automatic TCP retry
- UDP auto-disable: after 3 consecutive failures, switch to TCP-first
- IPv6 → TCP directly (UDP socket binds 0.0.0.0, can't reach IPv6)
- Network change resets UDP detection for re-probing
- Root hint rotation in TLD priming

Privacy:
- RFC 7816 query minimization: root servers see TLD only, not full name

Code quality:
- Merged find_starting_ns + find_starting_zone → find_closest_ns
- Extracted resolve_ns_addrs_from_glue shared helper
- Removed overall timeout wrapper (per-hop timeouts sufficient)
- forward_tcp for DNS-over-TCP (RFC 1035 §4.2.2)

Testing:
- Mock TCP-only DNS server for fallback tests (no network needed)
- tcp_fallback_resolves_when_udp_blocked
- tcp_only_iterative_resolution
- tcp_fallback_handles_nxdomain
- udp_auto_disable_resets
- Integration test suite (4 suites, 51 tests)
- Network probe script (tests/network-probe.sh)

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

* feat: DNSSEC verified badge in dashboard query log

- Add dnssec field to QueryLogEntry, track validation status per query
- DnssecStatus::as_str() for API serialization
- Dashboard shows green checkmark next to DNSSEC-verified responses
- Blog post: add "How keys get there" section, transport resilience section,
  trim code blocks, update What's Next

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

* fix: use SVG shield for DNSSEC badge, update blog HTML

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

* fix: NS cache lookup from authorities, UDP re-probe, shield alignment

- find_closest_ns checks authorities (not just answers) for NS records,
  fixing TLD priming cache misses that caused redundant root queries
- Periodic UDP re-probe every 5min when disabled — re-enables UDP
  after switching from a restrictive network to an open one
- Dashboard DNSSEC shield uses fixed-width container for alignment
- Blog post: tuck key-tag into trust anchor paragraph

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

* fix: TCP single-write, mock server consistency, integration tests

- TCP single-write fix: combine length prefix + message to avoid split
  segments that Microsoft/Azure DNS servers reject
- Mock server (spawn_tcp_dns_server) updated to use single-write too
- Tests: forward_tcp_wire_format, forward_tcp_single_segment_write
- Integration: real-server checks for Microsoft/Office/Azure domains

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

* feat: recursive bar in dashboard, special-use domain interception

Dashboard:
- Add Recursive bar to resolution paths chart (cyan, distinct from Override)
- Add RECURSIVE path tag style in query log

Special-use domains (RFC 6761/6303/8880/9462):
- .localhost → 127.0.0.1 (RFC 6761)
- Private reverse PTR (10.x, 192.168.x, 172.16-31.x) → NXDOMAIN
- _dns.resolver.arpa (DDR) → NXDOMAIN
- ipv4only.arpa (NAT64) → 192.0.0.170/171
- mDNS service discovery for private ranges → NXDOMAIN

Eliminates ~900ms SERVFAILs for macOS system queries that were
hitting root servers unnecessarily.

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

* chore: move generated blog HTML to site/blog/posts/, gitignore

- Generated HTML now in site/blog/posts/ (gitignored)
- CI workflow runs pandoc + make blog before deploy
- Updated all internal blog links to /blog/posts/ path
- blog/*.md remains the source of truth

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

* fix: review feedback — memory ordering, RRSIG time, NS resolution

- Ordering::Relaxed → Acquire/Release for UDP_DISABLED/UDP_FAILURES
  (ARM correctness for cross-thread coordination)
- RRSIG time validation: serial number arithmetic (RFC 4034 §3.1.5)
  + 300s clock skew fudge factor (matches BIND)
- resolve_ns_addrs_from_glue collects addresses from ALL NS names,
  not just the first with glue (improves failover)
- is_special_use_domain: eliminate 16 format! allocations per
  .in-addr.arpa query (parse octet instead)

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

* feat: API endpoint tests, coverage target

- 8 new axum handler tests: health, stats, query-log, overrides CRUD,
  cache, blocking stats, services CRUD, dashboard HTML
- Tests use tower::oneshot — no network, no server startup
- test_ctx() builds minimal ServerCtx for isolated testing
- `make coverage` target (cargo-tarpaulin), separate from `make all`
- 82 total tests (was 74)

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 04:03:47 +02:00
Razvan Dimescu
cc8d3c7a83 add Dev.to cover image (dashboard screenshot 1000x420)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 03:20:28 +02:00
Razvan Dimescu
4dec0c89b5 docs: update README — add numa.rs link, benchmarks, Windows support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:28:38 +02:00
Razvan Dimescu
ea840f5a07 Change artifact upload path for GitHub Pages 2026-03-27 02:22:43 +02:00
Razvan Dimescu
df2856b57f feat: self-host fonts, styled block page, wildcard TLS (#16)
* perf: optimize hot path — RwLock, inline filtering, pre-allocated strings

- Mutex → RwLock for cache, blocklist, and overrides (concurrent read access)
- Make cache.lookup() and overrides.lookup() take &self (read-only)
- Eliminate 3 Vec allocations per DnsPacket::write() via inline filtering
- Pre-allocate domain strings with capacity 64 in parse path
- Add criterion micro-benchmarks (hot_path + throughput)
- Add bench README documenting both benchmark suites

Measured improvement: ~14% faster parsing, ~9% pipeline throughput,
round-trip cached 733ns → 698ns (~2.3M queries/sec).

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

* chore: simplify benchmark code after review

- Remove redundant DnsHeader::new() (already set by DnsPacket::new())
- Remove unused DnsHeader import
- Change simulate_cached_pipeline to take &DnsCache (lookup is &self now)
- Remove unnecessary mut on cache in cache_lookup_miss bench

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

* site: landing page overhaul, blog, benchmarks, numa.rs domain

Landing page:
- Split features into 3-layer card layout (Block & Protect, Developer Tools, Self-Sovereign DNS)
- Add DoH and conditional forwarding to comparison table
- Fix performance claim (2.3M → 2.0M qps to match benchmarks)
- Add all 3 install methods (brew, cargo, curl)
- Add OG tags + canonical URL for numa.rs
- Fix code block whitespace rendering
- Update roadmap with .onion bridge phase

Blog:
- Add "Building a DNS Resolver from Scratch in Rust" post
- Blog index + template for future posts

Other:
- CNAME for GitHub Pages (numa.rs)
- Benchmark results (bench/results.json)

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

* feat: self-host fonts, styled block page, wildcard TLS

Fonts:
- Replace Google Fonts CDN with self-hosted woff2 (73KB, 5 files)
- Serve fonts from API server via include_bytes! (dashboard works offline)
- Proxy error pages use system fonts (zero external deps when DNS is broken)
- Fix Instrument Serif font-weight: use 400 (only available weight) instead of synthetic bold 600/700

Proxy:
- Styled "Blocked by Numa" page when blocked domain hits the proxy (was confusing "not a .numa domain" error)
- Extract shared error_page() template for 403 + 404 pages (deduplicate ~160 lines of CSS)

TLS:
- Add wildcard SAN *.numa to cert — unregistered .numa domains get valid HTTPS (styled 404 without cert warning)

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:19:54 +02:00
Razvan Dimescu
236ef7b4f5 perf: optimize DNS query hot path (#15)
* perf: optimize hot path — RwLock, inline filtering, pre-allocated strings

- Mutex → RwLock for cache, blocklist, and overrides (concurrent read access)
- Make cache.lookup() and overrides.lookup() take &self (read-only)
- Eliminate 3 Vec allocations per DnsPacket::write() via inline filtering
- Pre-allocate domain strings with capacity 64 in parse path
- Add criterion micro-benchmarks (hot_path + throughput)
- Add bench README documenting both benchmark suites

Measured improvement: ~14% faster parsing, ~9% pipeline throughput,
round-trip cached 733ns → 698ns (~2.3M queries/sec).

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

* chore: simplify benchmark code after review

- Remove redundant DnsHeader::new() (already set by DnsPacket::new())
- Remove unused DnsHeader import
- Change simulate_cached_pipeline to take &DnsCache (lookup is &self now)
- Remove unnecessary mut on cache in cache_lookup_miss bench

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:01:08 +02:00
Razvan Dimescu
5d454cbed5 update crate metadata + add deploy.sh release script
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:45:15 +02:00
Razvan Dimescu
c1d425069f bump version to 0.5.0
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 00:41:07 +02:00
Razvan Dimescu
d274500308 feat: DNS-over-HTTPS (DoH) upstream forwarding (#14)
* feat: DNS-over-HTTPS upstream forwarding

Encrypt upstream queries via DoH — ISPs see HTTPS traffic on port 443,
not plaintext DNS on port 53. URL scheme determines transport:
https:// = DoH, bare IP = plain UDP. Falls back to Quad9 DoH when
system resolver cannot be detected.

- Upstream enum (Udp/Doh) with Display and PartialEq
- BytePacketBuffer::from_bytes constructor
- reqwest http2 feature for DoH server compatibility
- network_watch_loop guards against DoH→UDP silent downgrade
- 5 new tests (mock DoH server, HTTP errors, timeout)

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

* style: cargo fmt

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

* docs: add DoH to README — Why Numa, comparison table, roadmap

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 00:39:58 +02:00
Razvan Dimescu
9c313ef06a docs: reorder README for launch — lead with unique features, add install methods
Comparison table and "Why Numa" reordered so unique capabilities (service proxy,
path routing, LAN discovery) appear first. Added brew/cargo install to Quick Start.
Removed unshipped "Self-sovereign DNS" row from comparison table. Named hickory-dns
and trust-dns in "How It Works" to signal deliberate architectural choice.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:16:50 +02:00
Razvan Dimescu
0d25fae4cf Merge pull request #13 from razvandimescu/fix/tls-hot-reload
fix: TLS cert hot-reload when services change
2026-03-23 19:46:05 +02:00
Razvan Dimescu
1ae2e23bb6 fix: regenerate TLS cert when services change (hot-reload via ArcSwap)
HTTPS proxy certs were generated once at startup. Services added at
runtime via API or LAN discovery got "not secure" in the browser
because their SAN wasn't in the cert. Now the cert is regenerated
on every service add/remove and swapped atomically via ArcSwap.
In-flight connections are unaffected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:14:06 +02:00
Razvan Dimescu
fe784addd2 release: auto-publish to crates.io on tag push
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:41:21 +02:00
Razvan Dimescu
a3a218ba5e numa.toml: add commented [blocking] section for discoverability
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 14:02:43 +02:00
Razvan Dimescu
e4594c7955 bump version to 0.4.0
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 13:57:53 +02:00
Razvan Dimescu
b85f599b8f Merge pull request #12 from razvandimescu/feat/community-feedback-improvements
LAN opt-in, mDNS, security hardening, path routing
2026-03-23 13:55:19 +02:00
Razvan Dimescu
03c164e339 dynamic banner width, hoist HTML escaper, cache CA, restore log path
- banner box width adapts to longest value (fixes overflow with long paths)
- hoist h() HTML escape function to script top, remove 3 local copies
- serve_ca: add Cache-Control: public, max-age=86400
- restore log path in dashboard footer alongside new config/data fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:29:18 +02:00
Razvan Dimescu
2fce82e36c config visibility, PR review fixes, XSS hardening
Config visibility:
- startup banner shows config path, data dir, services path
- config search: ./numa.toml → ~/.config/numa/ → /usr/local/var/numa/
- /stats API exposes config_path and data_dir, dashboard footer renders them
- GET /ca.pem endpoint serves CA cert for cross-device TLS trust
- load_config returns ConfigLoad with found flag, warns on not-found
- ServerCtx stores PathBuf for config_dir/data_dir, string conversion at boundaries

PR review fixes:
- add explicit parens in resolve_route operator precedence (service_store.rs)
- hostname portability: drop -s flag, trim domain with split('.') (lan.rs)
- serve_ca uses spawn_blocking instead of sync fs::read in async handler
- load_config: remove TOCTOU exists() check, read directly and handle NotFound

XSS hardening:
- HTML-escape all user-controlled interpolations in dashboard (service names,
  route paths, ports, URLs, block check domain/reason)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 12:24:21 +02:00
Razvan Dimescu
53ae4d1404 address PR review: SRV port, drop spike, percent-encoded paths
- SRV record uses first service's port (was 0, confused dns-sd -L)
- Remove examples/mdns_coexist.rs (served its purpose as spike)
- Reject percent-encoding in route paths (defense-in-depth)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:21:09 +02:00
Razvan Dimescu
4748a4a4bb dashboard: show LAN status in Local Services panel header
- Add lan_enabled to ServerCtx
- Add lan field to /stats API (enabled, peer count)
- Dashboard shows "LAN off" (dim) or "LAN on · N peers" (green)
- Tooltip shows enable command or mDNS service type

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:16:52 +02:00
Razvan Dimescu
607470472d README: add numa lan on command to LAN discovery section
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:12:53 +02:00
Razvan Dimescu
0dd7700665 simplify set_lan_enabled: fix config path, TOCTOU, double iteration
- Accept config path parameter (consistent with main's resolution)
- Read first, match on NotFound (eliminates TOCTOU race)
- Single position() call replaces any() + position()
- Precise key matching via split_once('=')
- Preserve original indentation on replacement
- Extract print_lan_status helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:59:35 +02:00
Razvan Dimescu
dddc10336c add numa lan on/off CLI command, update README
- numa lan on/off toggles LAN discovery in numa.toml
- Writes [lan] section if missing, updates enabled if present
- Colored output with restart hint
- README: add lan on/off to help text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:30:22 +02:00
Razvan Dimescu
4e723e8ee7 update README: mDNS, path routing, security defaults, opt-in LAN
- LAN discovery section: multicast → mDNS, add opt-in config example
- Add path-based routing to Why Numa, Local Service Proxy, comparison table, roadmap
- Update developer overrides: 25+ endpoints, mention /diagnose
- Comparison table: add path-based routing row
- Diagram: multicast → mDNS label

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:14:18 +02:00
Razvan Dimescu
03ca0bcb28 dashboard: route CRUD, source-aware service controls, XSS fix
- Add inline route management (+ route / x) per service in dashboard
- Expose service source (config vs api) in API response
- Only show service delete button for API-created services
- Pre-fill route port with service target_port
- Fix XSS in route path onclick handlers
- Skip renderServices refresh while route form is open (editingRoute guard)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 08:58:31 +02:00
Razvan Dimescu
c021d5a0c8 add unit tests for route matching, config defaults, and service store
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 07:49:22 +02:00
Razvan Dimescu
ed12659b26 fmt: fix proxy.rs formatting for CI rustfmt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 07:13:58 +02:00
Razvan Dimescu
eaab406515 simplify: unify route structs, fix prefix collision, lint fixes
- Unify RouteConfig/RouteEntry/RouteResponse into single RouteEntry
- Fix prefix collision: /api no longer matches /apiary (segment boundary check)
- Add path traversal rejection in route API
- Extract MdnsAnnouncement struct (clippy type_complexity)
- cargo fmt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 06:57:57 +02:00
Razvan Dimescu
9992418908 LAN opt-in, mDNS migration, security hardening, path-based routing
- LAN discovery disabled by default (opt-in via [lan] enabled = true)
- Replace custom JSON multicast (239.255.70.78:5390) with standard mDNS
  (_numa._tcp.local on 224.0.0.251:5353) using existing DNS parser
- Instance ID in TXT record for multi-instance self-filtering
- API and proxy bind to 127.0.0.1 by default (0.0.0.0 when LAN enabled)
- Path-based routing: longest prefix match with optional prefix stripping
  via [[services]] routes = [{path, port, strip?}]
- REST API: GET/POST/DELETE /services/{name}/routes
- Dashboard shows route lines per service when configured
- Segment-boundary route matching (prevents /api matching /apiary)
- Route path validation (rejects path traversal)

Closes #11

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 06:56:31 +02:00
Razvan Dimescu
0a43feaf1a Merge pull request #10 from razvandimescu/fix/fast-network-detect
Reduce network change detection to 5s
2026-03-22 21:47:25 +02:00
Razvan Dimescu
1bf11190d5 reduce network change detection to 5s with tiered polling
LAN IP checked every 5s (cheap UDP socket call). Full upstream
re-detection runs every 30s as safety net, or immediately when
LAN IP changes. Reduces worst-case network switch recovery from
30s to 5s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 19:36:03 +02:00
Razvan Dimescu
4f8afcd5b2 bump version to 0.3.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:32:48 +02:00
Razvan Dimescu
71cf0f0fc5 Merge pull request #9 from razvandimescu/fix/upstream-redetect
Fix DNS failure on network change
2026-03-22 11:23:36 +02:00
Razvan Dimescu
2b64e30bf7 show upstream DNS in stats API and dashboard footer
Expose current upstream address in /stats response. Dashboard footer
now shows "Upstream: x.x.x.x:53" — updates live when the network
watcher swaps the upstream.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:04:54 +02:00
Razvan Dimescu
4a1c98b02d fix circular reference: detect DHCP DNS when scutil shows loopback
When numa install is active, scutil --dns only returns 127.0.0.1.
Previously fell back to 9.9.9.9 (Quad9) which fails on networks
that block external DNS. Now reads DHCP-provided DNS from
ipconfig getpacket en0/en1 as intermediate fallback before Quad9.

Tested on a network that blocks 8.8.8.8, 9.9.9.9, 1.1.1.1 but
allows ISP DNS (213.154.124.25) — Numa now auto-detects and uses it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 10:24:54 +02:00
Razvan Dimescu
55ea49b003 generalize upstream re-detection into network change watcher
Always detect network changes (LAN IP, upstream, peers) regardless
of upstream config. LAN IP is now tracked in ServerCtx and updated
every 30s — multicast announcements use the current IP instead of
the startup IP. Upstream re-detection still only runs when
auto-detected. Peer flush triggers on any network change.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 09:38:09 +02:00
Razvan Dimescu
f01b2418cd fix DNS failure on network change with upstream re-detection
Upstream DNS was resolved once at startup and never updated. Switching
Wi-Fi networks made all queries fail until restart.

Now spawns a background task (every 30s) that re-runs system DNS
discovery and swaps the upstream atomically if it changed. Also flushes
stale LAN peers from the old network on change.

Only activates when upstream is auto-detected (not explicitly configured).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 09:31:49 +02:00
Razvan Dimescu
32bff69113 Merge pull request #8 from razvandimescu/feat/windows-support
Add Windows support (Phase 1)
2026-03-22 08:38:10 +02:00
Razvan Dimescu
0a39d98861 fix needless return in trust_ca for Windows clippy
On Windows, the not(macos/linux) cfg block is the only path, so
clippy flags the return as needless. Use expression form instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 08:29:28 +02:00
Razvan Dimescu
ca1f51652b fix Windows clippy errors and unreachable code
Gate version detection behind cfg(unix), fix unreachable Ok(()) after
return in trust_ca, use next_back() and is_some_and() per clippy.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 08:23:25 +02:00
Razvan Dimescu
a74d9a4bbb add Windows support (Phase 1)
Cross-platform paths: config_dir() uses %APPDATA%, data_dir() uses
%PROGRAMDATA% on Windows. TLS cert directory uses data_dir() instead
of hardcoded /usr/local/var/numa. Windows DNS discovery via ipconfig.
Fixed cfg gates from not(macos) to explicit linux to prevent Linux
code compiling on Windows. Added Windows target to CI and release
workflows with zip packaging.

System integration (numa install/service) not yet supported on Windows
— users run numa.exe manually.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 08:13:53 +02:00
Razvan Dimescu
e564bd887e updated hero image 2026-03-22 08:04:37 +02:00
Razvan Dimescu
8bece0a0cd Merge pull request #7 from razvandimescu/feat/lan-discovery
Add LAN service discovery via UDP multicast
2026-03-22 08:03:32 +02:00
Razvan Dimescu
990c865f41 update demo script for new dashboard layout and LAN badges
Reorder scenes to show services first (matching panel order),
scroll to blocking panel for domain check scene. LAN badge
now visible after adding a service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:04:06 +02:00
Razvan Dimescu
0ba2d3c72d update README, dashboard layout, and version bump to 0.3.0
Add LAN discovery section to README with mesh and hub mode docs.
Update comparison table and roadmap. Move Local Services panel
above Blocking in dashboard for developer-first layout.
Bump version from 0.1.0 to 0.3.0 to match release cadence.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:59:47 +02:00
Razvan Dimescu
def89ffe59 add LAN accessibility indicator for services
Show whether each service is reachable from the network or bound to
localhost only. Dashboard displays green "LAN" or amber "local only"
badge next to each healthy service. Unified TCP check function,
concurrent health+LAN probes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 06:35:12 +02:00
Razvan Dimescu
a29e4aeb96 fix LAN discovery: instance-based self-filter and multicast port reuse
Replace IP-based self-announcement filtering with a per-process instance
ID (pid ^ timestamp) so multiple instances on the same host can discover
each other. Enable SO_REUSEPORT for multicast socket binding on Unix.
Add multicast address validation on configured group.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 00:20:33 +02:00
Razvan Dimescu
d355f8d005 fix rustfmt formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:54:03 +02:00
Razvan Dimescu
c410945222 add LAN service discovery via UDP multicast
Numa instances on the same network auto-discover each other's .numa
services. No config, no cloud — just multicast on 239.255.70.78:5390.

- PeerStore with lazy expiry (90s timeout, 30s broadcast interval)
- DNS resolves remote .numa services to peer's LAN IP (not localhost)
- Proxy forwards to peer IP for remote services
- Graceful degradation if multicast bind fails

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:45:46 +02:00
Razvan Dimescu
b3f3a4f36c fix aarch64 musl build: use cross instead of musl.cc download
musl.cc was unreachable from CI. cross handles the Docker-based
cross-compilation automatically.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 13:01:59 +02:00
Razvan Dimescu
14b035387b switch Linux builds to musl for static binaries
glibc-linked binaries fail on older distros (GLIBC_2.38 not found).
musl produces fully static binaries that work on any Linux.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:50:34 +02:00
Razvan Dimescu
d457ffc296 remove unused rustls-pemfile dependency
Dead code — certs are generated at startup, not loaded from PEM files.
Removes RUSTSEC-2025-0134 warning. Audit now passes clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 12:03:13 +02:00
Razvan Dimescu
8ab50844c2 fix audit: update rustls-webpki, ignore unmaintained pemfile warning
RUSTSEC-2026-0049 fixed by updating rustls-webpki 0.103.9 → 0.103.10.
RUSTSEC-2025-0134 (rustls-pemfile unmaintained) ignored — no replacement
available, warning only, not a vulnerability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 11:59:52 +02:00
Razvan Dimescu
e04afe5b70 add cargo-audit to Makefile lint target
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:53:09 +02:00
Razvan Dimescu
44113492f0 add CI/crates.io/license badges, cargo-audit in CI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:51:13 +02:00
Razvan Dimescu
ec41f32d4e clarify single binary — no PHP, no web server, no database
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 10:17:39 +02:00
Razvan Dimescu
a35b0ea23c updated hero 2026-03-21 04:49:18 +02:00
Razvan Dimescu
fbdb0a245f Merge pull request #6 from razvandimescu/feat/404-page
Styled 404 page for unregistered .numa domains
2026-03-21 04:33:59 +02:00
59 changed files with 863 additions and 6867 deletions

View File

@@ -1,19 +0,0 @@
pkgbase = numa-git
pkgdesc = Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS
pkgver = 0.10.1.r0.g0000000
pkgrel = 1
url = https://github.com/razvandimescu/numa
arch = x86_64
license = MIT
options = !lto
makedepends = cargo
makedepends = git
depends = gcc-libs
depends = glibc
provides = numa
conflicts = numa
backup = etc/numa.toml
source = numa::git+https://github.com/razvandimescu/numa.git
sha256sums = SKIP
pkgname = numa-git

View File

@@ -1,34 +0,0 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
patterns: ["*"]
update-types: ["minor", "patch"]
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "monthly"
commit-message:
prefix: "chore(deps)"
groups:
minor-and-patch:
patterns: ["*"]
update-types: ["minor", "patch"]

View File

@@ -13,7 +13,7 @@ jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
with: with:
components: rustfmt, clippy components: rustfmt, clippy
@@ -27,31 +27,13 @@ jobs:
- name: audit - name: audit
run: cargo install cargo-audit && cargo audit run: cargo install cargo-audit && cargo audit
check-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: clippy
run: cargo clippy -- -D warnings
- name: test
run: cargo test
check-windows: check-windows:
runs-on: windows-latest runs-on: windows-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2
- name: build - name: build
run: cargo build run: cargo build
- name: clippy - name: clippy
run: cargo clippy -- -D warnings run: cargo clippy -- -D warnings
- name: test
run: cargo test
- name: Upload binary
uses: actions/upload-artifact@v7
with:
name: numa-windows-x86_64
path: target/debug/numa.exe

View File

@@ -1,77 +0,0 @@
name: Bump Homebrew Tap
on:
workflow_call:
inputs:
version:
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
type: string
required: true
workflow_dispatch:
inputs:
version:
description: 'Version to bump (e.g. 0.10.0 or v0.10.0)'
required: true
permissions:
contents: read
jobs:
bump:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Determine version
id: ver
env:
INPUT_VERSION: ${{ inputs.version }}
run: |
V="${INPUT_VERSION#v}"
echo "version=$V" >> "$GITHUB_OUTPUT"
- name: Fetch sha256 checksums from release assets
id: shas
env:
V: ${{ steps.ver.outputs.version }}
run: |
set -euo pipefail
base="https://github.com/razvandimescu/numa/releases/download/v${V}"
for t in macos-aarch64 macos-x86_64 linux-aarch64 linux-x86_64; do
sha=$(curl -fsSL "${base}/numa-${t}.tar.gz.sha256" | awk '{print $1}')
if [ -z "$sha" ]; then
echo "ERROR: failed to fetch sha256 for $t" >&2
exit 1
fi
key=$(echo "$t" | tr '[:lower:]-' '[:upper:]_')
echo "SHA_${key}=${sha}" >> "$GITHUB_ENV"
done
- name: Clone homebrew-tap
env:
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
run: |
git clone "https://x-access-token:${HOMEBREW_TAP_GITHUB_TOKEN}@github.com/razvandimescu/homebrew-tap.git" tap
- name: Update formula
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
python3 scripts/update-homebrew-formula.py tap/numa.rb
echo "--- updated numa.rb ---"
cat tap/numa.rb
- name: Commit and push
working-directory: tap
env:
V: ${{ steps.ver.outputs.version }}
run: |
if git diff --quiet; then
echo "numa.rb already at v${V}, nothing to commit"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add numa.rb
git commit -m "chore: bump numa to v${V}"
git push origin main

View File

@@ -1,159 +0,0 @@
# `publish-aur.yml` - Arch Linux AUR Package Workflow
# --------------------
# This workflow automates the validation and publishing of the 'numa-git' package to the
# Arch User Repository (AUR). The AUR is a community-driven repository for Arch Linux users.
#
# Workflow Overview:
# 1. Validate: Builds and tests the package for Arch Linux x86_64 using a clean
# Arch Linux container.
# 2. Audit: Checks Rust dependencies for known security vulnerabilities using
# 'cargo-audit'.
# 3. Publish: If on the 'main' branch, it pushes the updated PKGBUILD and
# .SRCINFO to the AUR.
#
# Security Best Practices:
# - SHA Pinning: All GitHub Actions are pinned to a full-length commit SHA (e.g., v6.0.2 @ SHA)
# to ensure the code is immutable and protects against supply-chain attacks where a tag
# might be maliciously moved to a compromised commit.
# - SSH Hygiene: Uses ssh-agent to keep the private key in memory rather than on disk.
# - Audit: Runs 'cargo audit' to prevent publishing known vulnerable dependencies.
name: Publish - Arch Linux AUR Package
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
# The 'validate' job ensures that the PKGBUILD is correct and the software builds/tests
# successfully on Arch Linux before we attempt to publish it.
validate:
name: Validate PKGBUILD (${{ matrix.arch }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
arch: [x86_64]
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build and Test Package
timeout-minutes: 60
env:
AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }}
run: |
# We use a temporary directory to avoid Docker permission issues with the workspace.
mkdir -p build-dir
cp PKGBUILD build-dir/
docker run --rm -v $PWD/build-dir:/pkg -w /pkg archlinux:latest /bin/bash -c "
# ARCH LINUX SECURITY REQUIREMENT:
# 'makepkg' (the tool that builds Arch packages) refuses to run as root for safety.
# We must create a standard user and give them sudo access.
# Install build-time dependencies.
# 'base-devel' includes essential tools like gcc, make, and binutils.
# Install 'rust' directly to avoid the interactive virtual-package
# prompt for 'cargo' on current Arch images.
pacman -Syu --noconfirm --needed base-devel rust git sudo cargo-audit
useradd -m builduser
chown -R builduser:builduser /pkg
# Allow the build user to install dependencies during the build process.
echo 'builduser ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builduser
# Fetch the source tree first so pkgver() and cargo-audit have a
# real Cargo.lock to inspect.
sudo -u builduser makepkg -o --nobuild --nocheck --nodeps --noprepare
# SECURITY AUDIT:
# Fail early if any dependencies have known security vulnerabilities.
sudo -u builduser sh -lc 'cd /pkg/src/numa && cargo audit'
# BUILD & TEST:
# 'makepkg -s' will:
# 1. Download source files (cloning this repo)
# 2. Run prepare(), build(), and check() (running cargo test)
# 3. Create the final .pkg.tar.zst package
sudo -u builduser makepkg -s --noconfirm
"
# The 'publish' job updates the AUR repository with our latest PKGBUILD and .SRCINFO.
publish:
name: Publish to AUR
needs: validate
runs-on: ubuntu-latest
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Securely configure SSH for AUR access.
- name: Configure SSH
run: |
mkdir -p ~/.ssh
# Official AUR Ed25519 fingerprint (prevents Man-in-the-Middle attacks).
echo "aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN" >> ~/.ssh/known_hosts
# Use ssh-agent to keep the private key in memory rather than writing it to disk.
eval $(ssh-agent -s)
echo "${{ secrets.AUR_SSH_PRIVATE_KEY }}" | tr -d '\r' | ssh-add -
# Export the agent socket so subsequent 'git' commands can use it.
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> $GITHUB_ENV
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> $GITHUB_ENV
- name: Push to AUR
env:
AUR_PKGNAME: ${{ secrets.AUR_PACKAGE_NAME }}
AUR_EMAIL: ${{ secrets.AUR_EMAIL }}
AUR_USER: ${{ secrets.AUR_USERNAME }}
run: |
# AUR repos are managed via Git. Each package has its own repo at:
# ssh://aur@aur.archlinux.org/<package-name>.git
git clone ssh://aur@aur.archlinux.org/$AUR_PKGNAME.git aur-repo
cp PKGBUILD aur-repo/
cd aur-repo
# METADATA GENERATION:
# '.SRCINFO' is a machine-readable version of the PKGBUILD.
# We must run this as a non-root user ('builduser') inside the container.
docker run --rm -v $(pwd):/pkg archlinux:latest /bin/bash -c "
pacman -Syu --noconfirm --needed binutils git sudo
useradd -m builduser
chown -R builduser:builduser /pkg
cd /pkg
sudo -u builduser git config --global --add safe.directory '*'
# makepkg -od fetches the source first so pkgver() can calculate the version.
# --noprepare skips the prepare() function, which invokes cargo and would
# otherwise require a full rust toolchain in this metadata-only container.
# pkgver() runs before prepare(), so .SRCINFO still gets the correct version.
sudo -u builduser makepkg -od --noprepare && sudo -u builduser makepkg --printsrcinfo > .SRCINFO
"
# Reclaim ownership: the in-container 'chown -R builduser:builduser /pkg'
# propagates through the bind mount, leaving .git/ owned by the container's
# builduser UID. Without this, subsequent 'git config' on the host fails with
# "could not lock config file .git/config: Permission denied".
sudo chown -R "$(id -u):$(id -g)" .
# Set the commit identity using secrets for security and auditability.
git config user.name "$AUR_USER"
git config user.email "$AUR_EMAIL"
# Stage and commit both the human-readable PKGBUILD and machine-readable .SRCINFO.
git add PKGBUILD .SRCINFO
if ! git diff --cached --quiet; then
git commit -m "chore: update PKGBUILD to ${{ github.sha }}"
git push origin master
else
echo "No changes to commit (metadata and PKGBUILD are already up-to-date)."
fi

View File

@@ -31,7 +31,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -70,7 +70,7 @@ jobs:
(Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii (Get-FileHash "${{ matrix.name }}.zip" -Algorithm SHA256).Hash.ToLower() + " ${{ matrix.name }}.zip" | Out-File "${{ matrix.name }}.zip.sha256" -Encoding ascii
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v4
with: with:
name: ${{ matrix.name }} name: ${{ matrix.name }}
path: | path: |
@@ -82,7 +82,7 @@ jobs:
publish: publish:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v4
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -96,7 +96,7 @@ jobs:
needs: [build, publish] needs: [build, publish]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/download-artifact@v8 - uses: actions/download-artifact@v4
with: with:
merge-multiple: true merge-multiple: true
@@ -108,10 +108,3 @@ jobs:
*.tar.gz *.tar.gz
*.zip *.zip
*.sha256 *.sha256
bump-homebrew:
needs: release
uses: ./.github/workflows/homebrew-bump.yml
with:
version: ${{ github.ref_name }}
secrets: inherit

View File

@@ -30,18 +30,18 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v4
- name: Install pandoc - name: Install pandoc
run: sudo apt-get install -y pandoc run: sudo apt-get install -y pandoc
- name: Generate blog HTML - name: Generate blog HTML
run: make blog run: make blog
- name: Setup Pages - name: Setup Pages
uses: actions/configure-pages@v6 uses: actions/configure-pages@v5
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v4 uses: actions/upload-pages-artifact@v3
with: with:
# Upload entire repository # Upload entire repository
path: './site' path: './site'
- name: Deploy to GitHub Pages - name: Deploy to GitHub Pages
id: deployment id: deployment
uses: actions/deploy-pages@v5 uses: actions/deploy-pages@v4

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
/target /target
/build-dir
CLAUDE.md CLAUDE.md
docs/ docs/
site/blog/posts/ site/blog/posts/

236
Cargo.lock generated
View File

@@ -17,15 +17,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "alloca"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
dependencies = [
"cc",
]
[[package]] [[package]]
name = "anes" name = "anes"
version = "0.1.6" version = "0.1.6"
@@ -93,9 +84,9 @@ dependencies = [
[[package]] [[package]]
name = "asn1-rs" name = "asn1-rs"
version = "0.7.1" version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048"
dependencies = [ dependencies = [
"asn1-rs-derive", "asn1-rs-derive",
"asn1-rs-impl", "asn1-rs-impl",
@@ -103,15 +94,15 @@ dependencies = [
"nom", "nom",
"num-traits", "num-traits",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 1.0.69",
"time", "time",
] ]
[[package]] [[package]]
name = "asn1-rs-derive" name = "asn1-rs-derive"
version = "0.6.0" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -377,24 +368,25 @@ dependencies = [
[[package]] [[package]]
name = "criterion" name = "criterion"
version = "0.8.2" version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
dependencies = [ dependencies = [
"alloca",
"anes", "anes",
"cast", "cast",
"ciborium", "ciborium",
"clap", "clap",
"criterion-plot", "criterion-plot",
"is-terminal",
"itertools", "itertools",
"num-traits", "num-traits",
"once_cell",
"oorandom", "oorandom",
"page_size",
"plotters", "plotters",
"rayon", "rayon",
"regex", "regex",
"serde", "serde",
"serde_derive",
"serde_json", "serde_json",
"tinytemplate", "tinytemplate",
"walkdir", "walkdir",
@@ -402,9 +394,9 @@ dependencies = [
[[package]] [[package]]
name = "criterion-plot" name = "criterion-plot"
version = "0.8.2" version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
dependencies = [ dependencies = [
"cast", "cast",
"itertools", "itertools",
@@ -449,9 +441,9 @@ checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea"
[[package]] [[package]]
name = "der-parser" name = "der-parser"
version = "10.0.0" version = "9.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553"
dependencies = [ dependencies = [
"asn1-rs", "asn1-rs",
"displaydoc", "displaydoc",
@@ -522,16 +514,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@@ -720,6 +702,12 @@ 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 = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@@ -822,7 +810,7 @@ dependencies = [
"libc", "libc",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"socket2", "socket2 0.6.3",
"tokio", "tokio",
"tower-service", "tower-service",
"tracing", "tracing",
@@ -956,6 +944,17 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@@ -964,9 +963,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.13.0" version = "0.10.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
dependencies = [ dependencies = [
"either", "either",
] ]
@@ -1144,7 +1143,7 @@ dependencies = [
[[package]] [[package]]
name = "numa" name = "numa"
version = "0.11.0" version = "0.7.0"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"axum", "axum",
@@ -1156,15 +1155,13 @@ dependencies = [
"hyper", "hyper",
"hyper-util", "hyper-util",
"log", "log",
"qrcode",
"rcgen", "rcgen",
"reqwest", "reqwest",
"ring", "ring",
"rustls", "rustls",
"rustls-pemfile",
"serde", "serde",
"serde_json", "serde_json",
"socket2", "socket2 0.5.10",
"time", "time",
"tokio", "tokio",
"tokio-rustls", "tokio-rustls",
@@ -1174,9 +1171,9 @@ dependencies = [
[[package]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.8.1" version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9"
dependencies = [ dependencies = [
"asn1-rs", "asn1-rs",
] ]
@@ -1199,16 +1196,6 @@ version = "11.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "page_size"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "pem" name = "pem"
version = "3.0.6" version = "3.0.6"
@@ -1313,12 +1300,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1332,8 +1313,8 @@ dependencies = [
"quinn-udp", "quinn-udp",
"rustc-hash", "rustc-hash",
"rustls", "rustls",
"socket2", "socket2 0.6.3",
"thiserror", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
"web-time", "web-time",
@@ -1354,7 +1335,7 @@ dependencies = [
"rustls", "rustls",
"rustls-pki-types", "rustls-pki-types",
"slab", "slab",
"thiserror", "thiserror 2.0.18",
"tinyvec", "tinyvec",
"tracing", "tracing",
"web-time", "web-time",
@@ -1369,7 +1350,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"libc", "libc",
"once_cell", "once_cell",
"socket2", "socket2 0.6.3",
"tracing", "tracing",
"windows-sys 0.60.2", "windows-sys 0.60.2",
] ]
@@ -1440,9 +1421,9 @@ dependencies = [
[[package]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.14.7" version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [ dependencies = [
"pem", "pem",
"ring", "ring",
@@ -1565,15 +1546,6 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.14.0" version = "1.14.0"
@@ -1673,11 +1645,11 @@ dependencies = [
[[package]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "1.1.1" version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [ dependencies = [
"serde_core", "serde",
] ]
[[package]] [[package]]
@@ -1698,16 +1670,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.9" version = "0.3.9"
@@ -1726,6 +1688,16 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.3"
@@ -1779,13 +1751,33 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [ dependencies = [
"thiserror-impl", "thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
] ]
[[package]] [[package]]
@@ -1875,8 +1867,7 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "socket2 0.6.3",
"socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@@ -1917,42 +1908,44 @@ dependencies = [
[[package]] [[package]]
name = "toml" name = "toml"
version = "1.1.2+spec-1.1.0" version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"indexmap", "serde",
"serde_core",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
"toml_parser", "toml_edit",
"toml_writer",
"winnow",
] ]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [ dependencies = [
"serde_core", "serde",
] ]
[[package]] [[package]]
name = "toml_parser" name = "toml_edit"
version = "1.1.2+spec-1.1.0" version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap",
"serde",
"serde_spanned",
"toml_datetime",
"toml_write",
"winnow", "winnow",
] ]
[[package]] [[package]]
name = "toml_writer" name = "toml_write"
version = "1.1.1+spec-1.1.0" version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]] [[package]]
name = "tower" name = "tower"
@@ -2185,22 +2178,6 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
@@ -2210,12 +2187,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
@@ -2380,9 +2351,12 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.1" version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
@@ -2398,9 +2372,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
[[package]] [[package]]
name = "x509-parser" name = "x509-parser"
version = "0.18.1" version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69"
dependencies = [ dependencies = [
"asn1-rs", "asn1-rs",
"data-encoding", "data-encoding",
@@ -2410,7 +2384,7 @@ dependencies = [
"oid-registry", "oid-registry",
"ring", "ring",
"rusticata-macros", "rusticata-macros",
"thiserror", "thiserror 1.0.69",
"time", "time",
] ]

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "numa" name = "numa"
version = "0.11.0" version = "0.7.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"
@@ -10,11 +10,11 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool
categories = ["network-programming", "development-tools"] categories = ["network-programming", "development-tools"]
[dependencies] [dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "signal"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
axum = "0.8" axum = "0.8"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "1.1" toml = "0.8"
log = "0.4" log = "0.4"
env_logger = "0.11" env_logger = "0.11"
reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false } reqwest = { version = "0.12", features = ["rustls-tls", "gzip", "http2"], default-features = false }
@@ -22,18 +22,16 @@ hyper = { version = "1", features = ["client", "http1", "server"] }
hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] }
http-body-util = "0.1" http-body-util = "0.1"
futures = "0.3" futures = "0.3"
socket2 = { version = "0.6", features = ["all"] } socket2 = { version = "0.5", features = ["all"] }
rcgen = { version = "0.14", features = ["pem", "x509-parser"] } rcgen = { version = "0.13", features = ["pem", "x509-parser"] }
time = "0.3" time = "0.3"
rustls = "0.23" rustls = "0.23"
tokio-rustls = "0.26" tokio-rustls = "0.26"
arc-swap = "1" arc-swap = "1"
ring = "0.17" ring = "0.17"
rustls-pemfile = "2.2.0"
qrcode = { version = "0.14", default-features = false }
[dev-dependencies] [dev-dependencies]
criterion = { version = "0.8", features = ["html_reports"] } criterion = { version = "0.5", features = ["html_reports"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
http = "1" http = "1"

View File

@@ -1,4 +1,4 @@
FROM rust:1.94-alpine AS builder FROM rust:1.88-alpine AS builder
RUN apk add --no-cache musl-dev cmake make perl RUN apk add --no-cache musl-dev cmake make perl
WORKDIR /app WORKDIR /app
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
@@ -11,7 +11,7 @@ COPY numa.toml com.numa.dns.plist numa.service ./
RUN touch src/main.rs src/lib.rs RUN touch src/main.rs src/lib.rs
RUN cargo build --release RUN cargo build --release
FROM alpine:3.23 FROM alpine:3.20
COPY --from=builder /app/target/release/numa /usr/local/bin/numa COPY --from=builder /app/target/release/numa /usr/local/bin/numa
EXPOSE 53/udp 80/tcp 443/tcp 853/tcp 5380/tcp EXPOSE 53/udp 80/tcp 443/tcp 5380/tcp
ENTRYPOINT ["numa"] ENTRYPOINT ["numa"]

View File

@@ -1,4 +1,4 @@
.PHONY: all build lint fmt check audit test coverage bench clean deploy blog release .PHONY: all build lint fmt check audit test coverage bench clean deploy blog
all: lint build test all: lint build test
@@ -33,12 +33,6 @@ blog:
echo " $$f → site/blog/posts/$$name.html"; \ echo " $$f → site/blog/posts/$$name.html"; \
done done
release:
ifndef VERSION
$(error Usage: make release VERSION=0.8.0)
endif
./scripts/release.sh $(VERSION)
clean: clean:
cargo clean cargo clean

View File

@@ -1,62 +0,0 @@
# Maintainer: razvandimescu <razvan@dimescu.com>
pkgname=numa-git
_pkgname=numa
pkgver=0.10.1.r0.g0000000 # Placeholder — pkgver() rewrites this on each makepkg run
pkgrel=1
pkgdesc="Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
arch=('x86_64')
url="https://github.com/razvandimescu/numa"
license=('MIT')
options=('!lto')
depends=('gcc-libs' 'glibc')
makedepends=('cargo' 'git')
provides=("$_pkgname")
conflicts=("$_pkgname")
backup=('etc/numa.toml')
source=("$_pkgname::git+$url.git")
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/$_pkgname"
( set -o pipefail
git describe --long --tags 2>/dev/null | sed 's/\([^-]*-g\)/r\1/;s/-/./g' ||
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
) | sed 's/^v//'
}
prepare() {
cd "$srcdir/$_pkgname"
# numa v0.10.1+ uses FHS-compliant paths on Linux by default
# (/var/lib/numa for data, journalctl for logs), so no source
# patching is needed. The earlier sed targeted /usr/local/bin/numa,
# which only appears in a comment in current main.
export RUSTUP_TOOLCHAIN=stable
cargo fetch --locked
}
build() {
cd "$srcdir/$_pkgname"
export RUSTUP_TOOLCHAIN=stable
cargo build --frozen --release
}
check() {
cd "$srcdir/$_pkgname"
export RUSTUP_TOOLCHAIN=stable
cargo test --frozen
}
package() {
cd "$srcdir/$_pkgname"
install -Dm755 "target/release/$_pkgname" "$pkgdir/usr/bin/$_pkgname"
# numa.service uses {{exe_path}} as a placeholder substituted by
# `numa install` at runtime via replace_exe_path(). For an AUR
# package install (no `numa install` step), we substitute it
# statically here so systemd gets a real ExecStart path.
sed 's|{{exe_path}}|/usr/bin/numa /etc/numa.toml|g' numa.service > numa.service.patched
install -Dm644 "numa.service.patched" "$pkgdir/usr/lib/systemd/system/numa.service"
install -Dm644 "numa.toml" "$pkgdir/etc/numa.toml"
install -Dm644 "LICENSE" "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}

223
README.md
View File

@@ -8,140 +8,189 @@
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required. A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). One ~8MB binary, everything embedded. Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Recursive resolution from root nameservers with full DNSSEC validation (chain-of-trust + NSEC/NSEC3 denial proofs). One ~8MB binary, no PHP, no web server, no database — everything is embedded.
![Numa dashboard](assets/hero-demo.gif) ![Numa dashboard](assets/hero-demo.gif)
## Quick Start ## Quick Start
```bash ```bash
# macOS # Install (pick one)
brew install razvandimescu/tap/numa brew install razvandimescu/tap/numa
cargo install numa
# Linux
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
# Arch Linux (AUR) # Run (port 53 requires root)
yay -S numa-git sudo numa
# Windows — download from GitHub Releases # Try it
# All platforms dig @127.0.0.1 google.com # ✓ resolves normally
cargo install numa dig @127.0.0.1 ads.google.com # ✗ blocked → 0.0.0.0
```
```bash
sudo numa # run in foreground (port 53 requires root/admin)
``` ```
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`) Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
Set as system DNS: ### Set as system resolver
| Platform | Install | Uninstall | ```bash
|----------|---------|-----------| # Point your system DNS to Numa (saves originals for uninstall)
| macOS | `sudo numa install` | `sudo numa uninstall` | sudo numa install
| Linux | `sudo numa install` | `sudo numa uninstall` |
| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot |
On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry. # Run as a persistent service (auto-starts on boot, restarts if killed)
sudo numa service start
```
## Local Services To uninstall: `sudo numa service stop` removes the service, `sudo numa uninstall` restores your original DNS.
Name your dev services instead of remembering port numbers: ### Upgrade
```bash
# From Homebrew
brew upgrade numa
# From source
make deploy # builds release, copies binary, re-signs, restarts service
```
### Build from source
```bash
git clone https://github.com/razvandimescu/numa.git && cd numa
cargo build --release
sudo cp target/release/numa /usr/local/bin/numa
```
## Why Numa
- **Local service proxy** — `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. Like `/etc/hosts` but with auto TLS, a REST API, LAN discovery, and auto-revert.
- **Path-based routing** — `app.numa/api → :5001`, `app.numa/auth → :5002`. Route URL paths to different backends with optional prefix stripping. Like nginx location blocks, zero config files.
- **LAN service discovery** — Numa instances on the same network find each other automatically via mDNS. Access a teammate's `api.numa` from your machine. Opt-in via `[lan] enabled = true`.
- **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. Full REST API for scripting. Built-in diagnostics: `curl localhost:5380/diagnose/example.com` tells you exactly how any domain resolves.
- **DNS-over-HTTPS** — upstream queries encrypted via DoH. Your ISP sees HTTPS traffic, not DNS queries. Set `address = "https://9.9.9.9/dns-query"` in `[upstream]` or any DoH provider.
- **Ad blocking that travels with you** — 385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network: coffee shops, hotels, airports.
- **Sub-microsecond caching** — 691ns cached round-trip, ~2.0M queries/sec throughput, zero heap allocations in the I/O path. [Benchmarks](bench/).
- **Live dashboard** — real-time stats, query log, blocking controls, service management. LAN accessibility badges show which services are reachable from other devices.
- **macOS, Linux, and Windows** — `numa install` configures system DNS, `numa service start` runs as launchd/systemd service.
## Local Service Proxy
Name your local dev services with `.numa` domains:
```bash ```bash
curl -X POST localhost:5380/services \ curl -X POST localhost:5380/services \
-H 'Content-Type: application/json' \
-d '{"name":"frontend","target_port":5173}' -d '{"name":"frontend","target_port":5173}'
open http://frontend.numa # → proxied to localhost:5173
``` ```
Now `https://frontend.numa` works in your browser — green lock, valid cert, WebSocket passthrough for HMR. No mkcert, no nginx, no `/etc/hosts`. - **HTTPS with green lock** — auto-generated local CA + per-service TLS certs
- **WebSocket** — Vite/webpack HMR works through the proxy
- **Health checks** — dashboard shows green/red status per service
- **LAN sharing** — services bound to `0.0.0.0` are automatically discoverable by other Numa instances on the network. Dashboard shows "LAN" or "local only" per service.
- **Path-based routing** — route URL paths to different backends:
```toml
[[services]]
name = "app"
target_port = 3000
routes = [
{ path = "/api", port = 5001 },
{ path = "/auth", port = 5002, strip = true },
]
```
`app.numa/api/users → :5001/api/users`, `app.numa/auth/login → :5002/login` (stripped)
- **Persistent** — services survive restarts
- Or configure in `numa.toml`:
Add path-based routing (`app.numa/api → :5001`), share services across machines via LAN discovery, or configure everything in [`numa.toml`](numa.toml). ```toml
[[services]]
## Ad Blocking & Privacy name = "frontend"
target_port = 5173
385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop.
Three resolution modes:
- **`forward`** (default) — transparent proxy to your existing system DNS. Everything works as before, just with caching and ad blocking on top. Captive portals, VPNs, corporate DNS — all respected.
- **`recursive`** — resolve directly from root nameservers. No upstream dependency, no single entity sees your full query pattern. Add `[dnssec] enabled = true` for full chain-of-trust validation.
- **`auto`** — probe root servers on startup, recursive if reachable, encrypted DoH fallback if blocked.
DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
**DNS-over-TLS listener** (RFC 7858) — accept encrypted queries on port 853 from strict clients like iOS Private DNS, systemd-resolved, or stubby. Two modes:
- **Self-signed** (default) — numa generates a local CA automatically. `numa install` adds it to the system trust store on macOS, Linux (Debian/Ubuntu, Fedora/RHEL/SUSE, Arch), and Windows. On iOS, install the `.mobileconfig` from `numa setup-phone`. Firefox keeps its own NSS store and ignores the system one — trust the CA there manually if you need HTTPS for `.numa` services in Firefox.
- **Bring-your-own cert** — point `[dot] cert_path` / `key_path` at a publicly-trusted cert (e.g., Let's Encrypt via DNS-01 challenge on a domain pointing at your numa instance). Clients connect without any trust-store setup — same UX as AdGuard Home or Cloudflare `1.1.1.1`.
ALPN `"dot"` is advertised and enforced in both modes; a handshake with mismatched ALPN is rejected as a cross-protocol confusion defense.
**Phone setup** — point your iPhone or Android at Numa in one step:
```bash
numa setup-phone
``` ```
Prints a QR code. Scan it, install the profile, toggle certificate trust — your phone's DNS now routes through Numa over TLS. Requires `[mobile] enabled = true` in `numa.toml`. ## LAN Service Discovery
## LAN Discovery Run Numa on multiple machines. They find each other automatically:
Run Numa on multiple machines. They find each other automatically via mDNS:
``` ```
Machine A (192.168.1.5) Machine B (192.168.1.20) Machine A (192.168.1.5) Machine B (192.168.1.20)
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Numa │ mDNS │ Numa │ │ Numa │ mDNS │ Numa │
- api (port 8000) │◄───────────►│ - grafana (3000) services: │◄───────────►│ services:
- frontend (5173) │ discovery │ - api (port 8000) │ discovery │ - grafana (3000)
│ - frontend (5173) │ │ │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ └──────────────────────┘
``` ```
From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Enable with `numa lan on`. From Machine B:
```bash
dig @127.0.0.1 api.numa # → 192.168.1.5
curl http://api.numa # → proxied to Machine A's port 8000
```
**Hub mode**: run one instance with `bind_addr = "0.0.0.0:53"` and point other devices' DNS to it — they get ad blocking + `.numa` resolution without installing anything. Enable LAN discovery:
```bash
numa lan on
```
Or in `numa.toml`:
```toml
[lan]
enabled = true
```
Uses standard mDNS (`_numa._tcp.local` on port 5353) — compatible with Bonjour/Avahi, silently dropped by corporate firewalls instead of triggering IPS alerts.
**Hub mode** — don't want to install Numa on every machine? Run one instance as a shared DNS server and point other devices to it:
```bash
# On the hub machine, bind to LAN interface
[server]
bind_addr = "0.0.0.0:53"
# On other devices, set DNS to the hub's IP
# They get .numa resolution, ad blocking, caching — zero install
```
## How It Compares ## How It Compares
| | Pi-hole | AdGuard Home | Unbound | Numa | | | Pi-hole | AdGuard Home | NextDNS | Cloudflare | Numa |
|---|---|---|---|---| |---|---|---|---|---|---|
| Local service proxy + auto TLS | | | | `.numa` domains, HTTPS, WebSocket | | Local service proxy | No | No | No | No | `.numa` + HTTPS + WS |
| LAN service discovery | | | | mDNS, zero config | | Path-based routing | No | No | No | No | Prefix match + strip |
| Developer overrides (REST API) | | | | Auto-revert, scriptable | | LAN service discovery | No | No | No | No | mDNS, opt-in |
| Recursive resolver | | | Yes | Yes, with SRTT selection | | Developer overrides | No | No | No | No | REST API + auto-expiry |
| DNSSEC validation | — | — | Yes | Yes (RSA, ECDSA, Ed25519) | | Recursive resolver | No | No | Cloud only | Cloud only | From root hints, DNSSEC |
| Ad blocking | Yes | Yes | — | 385K+ domains | | Encrypted upstream (DoH) | No (needs cloudflared) | Yes | Cloud only | Cloud only | Native, single binary |
| Web admin UI | Full | Full | — | Dashboard | | Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary |
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native | | Zero config | Complex | Docker/setup | Yes | Yes | Works out of the box |
| Encrypted clients (DoT listener) | Needs stunnel sidecar | Yes | Yes | Native (RFC 7858) | | Ad blocking | Yes | Yes | Yes | Limited | 385K+ domains |
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows | | Data stays local | Yes | Yes | Cloud | Cloud | 100% local |
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
## Performance ## How It Works
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/) ```
Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Recursive/Forward
```
## Learn More Two resolution modes: **forward** (relay to upstream like Quad9/Cloudflare) or **recursive** (resolve from root nameservers — no upstream dependency). Set `mode = "recursive"` in `[upstream]` to resolve independently.
- [Blog: DNS-over-TLS from Scratch in Rust](https://numa.rs/blog/posts/dot-from-scratch.html) No DNS libraries — no `hickory-dns`, no `trust-dns`. The wire protocol — headers, labels, compression pointers, record types — is parsed and serialized by hand. Runs on `tokio` + `axum`, async per-query task spawning.
- [Blog: Implementing DNSSEC from Scratch in Rust](https://numa.rs/blog/posts/dnssec-from-scratch.html)
- [Blog: I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html) [Configuration reference](numa.toml)
- [Configuration reference](numa.toml) — all options documented inline
- [REST API](src/api.rs) — 27 endpoints across overrides, cache, blocking, services, diagnostics
## Roadmap ## Roadmap
- [x] DNS forwarding, caching, ad blocking, developer overrides - [x] DNS proxy core — forwarding, caching, local zones
- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy - [x] Developer overrides — REST API with auto-expiry
- [x] LAN service discovery — mDNS, cross-machine DNS + proxy - [x] Ad blocking — 385K+ domains, live dashboard, allowlist
- [x] DNS-over-HTTPS — encrypted upstream - [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery
- [x] DNS-over-TLS listener — encrypted client connections (RFC 7858, ALPN strict) - [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3 - [x] Path-based routing — URL prefix routing with optional strip, REST API
- [x] SRTT-based nameserver selection - [x] LAN service discovery — mDNS auto-discovery (opt-in), cross-machine DNS + proxy
- [x] Mobile onboarding — `setup-phone` QR flow, mobile API, mobileconfig profiles - [x] DNS-over-HTTPS — encrypted upstream via DoH (Quad9, Cloudflare, any provider)
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT - [x] Recursive resolution — resolve from root nameservers, no upstream dependency
- [ ] Global `.numa` names — DHT-backed, no registrar - [x] DNSSEC validation — chain-of-trust, NSEC/NSEC3 denial proofs, AD bit (RSA, ECDSA, Ed25519)
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes)
- [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served
## License ## License

View File

@@ -50,7 +50,17 @@ TLD priming solves this. On startup, Numa queries root for NS records of 34 comm
DNSSEC doesn't encrypt DNS traffic. It *signs* it. Every DNS record can have an accompanying RRSIG (signature) record. The resolver verifies the signature against the zone's DNSKEY, then verifies that DNSKEY against the parent zone's DS (delegation signer) record, walking up until it reaches the root trust anchor — a hardcoded public key that IANA publishes and the entire internet agrees on. DNSSEC doesn't encrypt DNS traffic. It *signs* it. Every DNS record can have an accompanying RRSIG (signature) record. The resolver verifies the signature against the zone's DNSKEY, then verifies that DNSKEY against the parent zone's DS (delegation signer) record, walking up until it reaches the root trust anchor — a hardcoded public key that IANA publishes and the entire internet agrees on.
<img src="../dnssec-chain.svg" alt="DNSSEC chain of trust diagram — verifying cloudflare.com from answer through .com TLD to root trust anchor"> ```
cloudflare.com A 104.16.132.229
signed by → RRSIG (key_tag=34505, algo=13, signer=cloudflare.com)
verified with → DNSKEY (cloudflare.com, key_tag=34505, ECDSA P-256)
vouched for by → DS (at .com, key_tag=2371, digest=SHA-256 of cloudflare's DNSKEY)
signed by → RRSIG (key_tag=19718, signer=com)
verified with → DNSKEY (com, key_tag=19718)
vouched for by → DS (at root, key_tag=30909)
signed by → RRSIG (signer=.)
verified with → DNSKEY (., key_tag=20326) ← root trust anchor (hardcoded)
```
### How keys get there ### How keys get there
@@ -155,20 +165,22 @@ The network fetch dominates. The crypto is noise.
## Surviving hostile networks ## Surviving hostile networks
I deployed Numa as my system DNS and switched networks. Everything broke — every query SERVFAIL, 3-second timeout. The ISP blocks outbound UDP port 53 to everything except whitelisted public resolvers. Root servers, TLD servers, authoritative servers — all unreachable over UDP. I deployed Numa as my system DNS and switched to a different network. Everything broke. Every query: SERVFAIL, 3-second timeout.
But TCP port 53 worked. Every DNS server is required to support TCP (RFC 1035 section 4.2.2). The ISP only filters UDP. The network probe told the story: the ISP blocks outbound UDP port 53 to all servers except a handful of whitelisted public resolvers (Google, Cloudflare). Root servers, TLD servers, authoritative servers — all unreachable over UDP. The ISP forces you onto their DNS or a blessed upstream. Recursive resolution is impossible.
Except TCP port 53 worked fine. And every DNS server is required to support TCP (RFC 1035 section 4.2.2). The ISP apparently only filters UDP.
The fix has three parts: The fix has three parts:
**TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit. **TCP fallback.** Every outbound query tries UDP first (800ms timeout). If UDP fails or the response is truncated, retry immediately over TCP. TCP uses a 2-byte length prefix before the DNS message — trivial to implement, and it handles DNSSEC responses that exceed the UDP payload limit.
**UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. The flag resets when the network changes (detected via LAN IP monitoring). **UDP auto-disable.** After 3 consecutive UDP failures, flip a global `AtomicBool` and skip UDP entirely — go TCP-first for all queries. This avoids burning 800ms per hop on a network where UDP will never work. The flag resets when the network changes (detected via LAN IP monitoring).
<img src="../hostile-network.svg" alt="Latency profile on a hostile network: queries 1-3 each spend 800ms waiting for a UDP timeout before retrying over TCP, taking 1,100ms total per query. After 3 consecutive failures the UDP auto-disable flag flips, and queries 4+ go TCP-first and complete in 300ms each — 3.7× faster.">
**Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours. **Query minimization (RFC 7816).** When querying root servers, send only the TLD — `com` instead of `secret-project.example.com`. Root servers handle trillions of queries and are operated by 12 organizations. Minimization reduces what they learn from yours.
The result: on a network that blocks UDP:53, Numa detects the block within the first 3 queries, switches to TCP, and resolves normally at 300-500ms per cold query. Cached queries remain 0ms. No manual config change needed — switch networks and it adapts.
I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works. I wouldn't have found this without dogfooding. The code worked perfectly on my home network. It took a real hostile network to expose the assumption that UDP always works.
## What I learned ## What I learned

View File

@@ -1,167 +0,0 @@
---
title: DNS-over-TLS from Scratch in Rust
description: Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, and two bugs that only the strict clients caught.
date: April 2026
---
The [previous post](/blog/posts/dnssec-from-scratch.html) ended with "DoT — the last encrypted transport we don't support." This post is about building it.
Numa now runs a DoT listener on port 853. My iPhone uses it as its system resolver, so ad blocking, DNSSEC validation, and recursive resolution follow my phone through the day. No cloud, no account, no companion app — a self-signed cert, a `.mobileconfig` profile, and a QR code in the terminal.
RFC 7858 is ten pages. The hard parts weren't in the RFC. They were in cross-protocol confusion defenses, a crypto-provider init gotcha that only triggered in one specific config combination, and a certificate SAN bug iOS was happy to accept and `kdig` immediately rejected. This post is about those parts.
## Why DoT when you already have DoH?
Numa has shipped DoH since v0.1. Both protocols tunnel DNS over TLS; DoH wraps queries in HTTP/2, DoT is DNS-over-TCP with TLS in front. Same privacy guarantees, different wrapper.
The answer to "why both" is that **phones ask for DoT by name.** iOS system DNS configures it with two fields (IP + server name) instead of a URL template. Android 9+ "Private DNS" speaks DoT natively. Linux stubs default to DoT. I wanted my phone on Numa without installing anything on the phone itself, and DoT is the protocol iOS and Android already speak for that.
## The wire format is refreshingly small
RFC 7858 is one sentence of wire protocol: *DNS-over-TCP (RFC 1035 §4.2.2) with TLS in front, on port 853.* DNS-over-TCP has existed since 1987 — a 2-byte length prefix followed by the DNS message. DoT is that, wrapped in a TLS session. The entire framing code is seven lines:
```rust
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> io::Result<()>
where S: AsyncWriteExt + Unpin {
let mut out = Vec::with_capacity(2 + msg.len());
out.extend_from_slice(&(msg.len() as u16).to_be_bytes());
out.extend_from_slice(msg);
stream.write_all(&out).await?;
stream.flush().await
}
```
Reads are symmetric: `read_exact` two bytes, convert to `u16`, `read_exact` that many bytes. No HTTP headers, no chunked encoding, no framing layer.
## Persistent connections
A fresh TCP+TLS handshake is at least 3 RTTs — about 300ms on a 100ms connection, 60× the cost of a UDP query. RFC 7858 §3.4 says clients SHOULD reuse the TCP connection for multiple queries, and every real DoT client does: iOS, Android, systemd, stubby. A single connection often carries hundreds of queries.
<img src="../dot-handshake.svg" alt="Timing diagram comparing a DNS lookup over plain UDP (1 RTT), over DoT on a fresh connection (3 RTTs — TCP handshake, TLS 1.3 handshake, then the query), and over a reused DoT session (1 RTT, same as UDP).">
The amortization point is the whole game. If you only ever do one query per connection, DoT is roughly 3× slower than UDP and you should not use it. If you reuse the same TLS session for a browsing session's worth of queries, the handshake is paid once and every subsequent query is effectively free.
The server is a loop that reads a length-prefixed message, resolves it, writes the response framed the same way, waits for the next one. Three timeouts keep it honest:
- **Handshake timeout (10s)** — a slowloris that opens TCP but never sends a ClientHello can't pin a worker.
- **Idle timeout (30s)** — a connected client with nothing to say gets dropped.
- **Write timeout (10s)** — a stalled reader can't hold a response buffer indefinitely.
A semaphore caps concurrent connections at 512 so a burst of handshakes can't exhaust the tokio runtime.
## ALPN, the cross-protocol defense that matters
If DoT lives on port 853 and HTTPS on 443, what stops an HTTP/2 client from hitting 853 and getting confused replies? [Cross-protocol attacks](https://alpaca-attack.com/) exist and have had real CVEs. The defense is ALPN: during the TLS handshake the client advertises protocols, the server picks one it supports or fails. A DoT server advertises `"dot"`; a client offering only `"h2"` gets a `no_application_protocol` fatal alert before any frames are exchanged.
rustls enforces this by default when you set `alpn_protocols`:
```rust
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
config.alpn_protocols = vec![b"dot".to_vec()];
```
"The library enforces it by default" has a latent risk: a future rustls upgrade could change the default, and the defense would quietly evaporate. I wrote a test that pins the behavior so any regression in a dependency update fails loudly:
```rust
#[tokio::test]
async fn dot_rejects_non_dot_alpn() {
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, vec![b"h2".to_vec()]);
let connector = tokio_rustls::TlsConnector::from(client_config);
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let result = connector
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
.await;
assert!(result.is_err(),
"DoT server must reject ALPN that doesn't include \"dot\"");
}
```
When you're leaning on a library's default for a security-critical invariant, the test is the contract.
## Two bugs that hid for days
Both were fixed before v0.10 shipped. Both stayed hidden because my initial tests used *permissive* clients.
### The rustls crypto provider panic
rustls 0.23 requires a `CryptoProvider` installed before you can build a `ServerConfig`. Numa's HTTPS proxy calls `install_default` as a side effect when it builds its own config, so DoT "just worked" for users who enabled both — the proxy had already initialized the provider before DoT's first handshake.
Then I added support for user-provided DoT certificates. Someone running DoT with their own Let's Encrypt cert, with the HTTPS proxy disabled, would hit:
```
thread 'dot' panicked at rustls-0.23.25/src/crypto/mod.rs:185:14:
no process-level CryptoProvider available -- call
CryptoProvider::install_default() before this point
```
The panic happened on the first client connection, not at startup. While writing the integration suite for "DoT with BYO cert, proxy disabled" — the one combination nobody had ever actually exercised — the first run panicked. Fix is two lines: call `install_default` inside `load_tls_config` so DoT can stand alone. If a side effect initializes something and you have a path that skips that side effect, you have a bug waiting for a specific deployment.
### The SAN bug iOS was happy to accept
Numa's self-signed DoT cert is generated on first run from a local CA alongside the data directory. It needs to match whatever `ServerName` the client sends as SNI. For the HTTPS proxy, that's the wildcard domain pattern `*.numa` (matching `frontend.numa`, `api.numa`, etc.). I initially reused the same SAN list for DoT: a wildcard `*.numa` and nothing else.
On an iPhone this worked perfectly. Full browsing session, persistent connections in the log, ad blocking active. I was about to merge when I ran one last smoke test with `kdig` (GnuTLS-backed, from [Knot DNS](https://www.knot-dns.cz/)):
```
$ kdig @192.168.1.16 -p 853 +tls \
+tls-ca=/usr/local/var/numa/ca.pem \
+tls-hostname=numa.numa example.com A
;; TLS, handshake failed (Error in the certificate.)
```
Huh.
[RFC 6125 §6.4.3](https://datatracker.ietf.org/doc/html/rfc6125#section-6.4.3): a wildcard in a certificate's DNS-ID matches exactly one label. `*.numa` matches `frontend.numa`, but not `numa.numa`, because the wildcard wants at least one label to substitute and strict clients reject wildcards in the leftmost label under single-label TLDs as ambiguous.
iOS's TLS stack is lenient and accepts it. GnuTLS, NSS (Firefox), and most non-Apple validators don't. The fix is five lines — add an explicit `numa.numa` SAN alongside the wildcard. But the lesson is the one that stuck: I wrote a commit message saying "fix an iOS bug" and had to rewrite it, because iOS was fine. The real bug was that every GnuTLS/NSS-based client on the planet would have rejected the cert, and I only found it by running one more test with a stricter tool.
> Test with the strict client. The permissive client hides your bugs.
## Getting your phone onto it
A DoT server is useless without a way to point a phone at it. iOS won't let you type an IP and a server name into Settings directly — you install a `.mobileconfig` profile that bundles the CA as a trust anchor and the DNS settings in a single payload.
Numa ships a subcommand that builds one on the fly and serves it over a QR code in the terminal:
```
$ numa setup-phone
Numa Phone Setup
Profile URL: http://192.168.1.16:8765/mobileconfig
█▀▀▀▀▀▀▀█▀▀██ ██ ▀█▀▀▀▀▀▀▀█
█ █▀▀▀█ █▀▄▀▀▀▀▄▄█ █▀▀▀█ █
...
On your iPhone:
1. Open Camera, point at the QR code, tap the yellow banner
2. Allow the download when Safari asks
3. Settings → "Profile Downloaded" → Install
4. Settings → General → About → Certificate Trust Settings
Toggle ON "Numa Local CA" — required for DoT to work
```
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.
## What I learned
**RFC-level small, API-level hard.** RFC 7858 is ten pages. The framing is trivial. But the subtle stuff — ALPN, timeouts, connection caps, handshake vs idle vs write deadlines, backoff on accept errors — isn't in the RFC. Miss any of it and you leak a DoS vector or a protocol confusion hole.
**Your test matrix is your security matrix.** Both bugs in this post were hidden by lenient clients. In both cases the strict client — kdig, or a specific config combination — surfaced the bug instantly. Pick test tools for strictness, not convenience. The moment you find yourself thinking "but iOS accepts it," stop and run kdig.
**Don't initialize global state via side effects.** "Module A installs a global, module B silently depends on it, disabling A breaks B" is a bug pattern that keeps coming back. Fix: have module B initialize its dependency explicitly, even if it means calling an idempotent `install_default` twice. The dependency graph should be local and obvious.
## What's next
- **DoH server** — Numa already has a DoH client; the other half unlocks Firefox's built-in DoH setting pointing at Numa.
- **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.
The code is at [github.com/razvandimescu/numa](https://github.com/razvandimescu/numa) — the DoT listener is in [`src/dot.rs`](https://github.com/razvandimescu/numa/blob/main/src/dot.rs) and the phone onboarding flow is in [`src/setup_phone.rs`](https://github.com/razvandimescu/numa/blob/main/src/setup_phone.rs) and [`src/mobileconfig.rs`](https://github.com/razvandimescu/numa/blob/main/src/mobileconfig.rs). MIT license.

View File

@@ -6,7 +6,7 @@
<string>com.numa.dns</string> <string>com.numa.dns</string>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>{{exe_path}}</string> <string>/usr/local/bin/numa</string>
</array> </array>
<key>RunAtLoad</key> <key>RunAtLoad</key>
<true/> <true/>

View File

@@ -70,10 +70,8 @@ echo ""
echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)" echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)"
echo "" echo ""
echo " Get started:" echo " Get started:"
echo " sudo numa install # install service + set as system DNS" echo " sudo numa # start the DNS server"
echo " open http://localhost:5380 # dashboard" echo " sudo numa install # set as system DNS"
echo "" echo " sudo numa service start # run as persistent service"
echo " Other commands:" echo " open http://localhost:5380 # dashboard"
echo " sudo numa # run in foreground (no service)"
echo " sudo numa uninstall # restore original DNS"
echo "" echo ""

View File

@@ -5,7 +5,7 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart={{exe_path}} ExecStart=/usr/local/bin/numa
Restart=always Restart=always
RestartSec=2 RestartSec=2
StandardOutput=journal StandardOutput=journal

View File

@@ -2,12 +2,6 @@
bind_addr = "0.0.0.0:53" bind_addr = "0.0.0.0:53"
api_port = 5380 api_port = 5380
# api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access # api_bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN dashboard access
# data_dir = "/var/lib/numa" # where numa stores TLS CA and cert material
# Defaults: /var/lib/numa on linux (FHS),
# /usr/local/var/numa on macos (homebrew prefix),
# %PROGRAMDATA%\numa on windows. Override for
# containerized deploys or tests that can't
# write to the system path.
# [upstream] # [upstream]
# mode = "forward" # "forward" (default) — relay to upstream # mode = "forward" # "forward" (default) — relay to upstream
@@ -60,7 +54,7 @@ enabled = true
port = 80 port = 80
tls_port = 443 tls_port = 443
tld = "numa" tld = "numa"
# bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN access to .numa services # bind_addr = "127.0.0.1" # default; auto 0.0.0.0 when [lan] enabled
# Pre-configured services (numa.numa is always added automatically) # Pre-configured services (numa.numa is always added automatically)
# [[services]] # [[services]]
@@ -89,35 +83,8 @@ tld = "numa"
# enabled = false # opt-in: verify chain of trust from root KSK # enabled = false # opt-in: verify chain of trust from root KSK
# strict = false # true = SERVFAIL on bogus signatures # strict = false # true = SERVFAIL on bogus signatures
# DNS-over-TLS listener (RFC 7858) — encrypted DNS on port 853
# [dot]
# enabled = false # opt-in: accept DoT queries
# port = 853 # standard DoT port
# 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)
# key_path = "/etc/numa/dot.key" # PEM private key; must be set together with cert_path
# LAN service discovery via mDNS (disabled by default — no network traffic unless enabled) # LAN service discovery via mDNS (disabled by default — no network traffic unless enabled)
# [lan] # [lan]
# enabled = true # discover other Numa instances via mDNS (_numa._tcp.local) # enabled = true # discover other Numa instances via mDNS (_numa._tcp.local)
# broadcast_interval_secs = 30 # broadcast_interval_secs = 30
# peer_timeout_secs = 90 # peer_timeout_secs = 90
# Mobile API — persistent HTTP listener serving read-only routes
# (/health, /ca.pem, /mobileconfig, /ca.mobileconfig) on a LAN-reachable
# port. Consumed by the iOS/Android companion apps for discovery and
# profile fetching, and by `numa setup-phone` for QR-based onboarding.
#
# Opt-in because the listener binds to the LAN by default. None of the
# exposed routes are cryptographically sensitive (no private keys, no
# state mutations, all idempotent GETs), but enabling it does add a new
# listener to any device on the LAN that scans port 8765.
#
# Safe for home LANs. Think twice before enabling on untrusted LANs
# (office Wi-Fi, coffee shops, etc.) — an attacker on the same network
# could run a competing Numa instance that shadows yours via mDNS and
# trick companion apps into installing their profile instead of yours.
[mobile]
enabled = true # opt-in to the mobile API listener
# port = 8765 # default; matches Discovery.swift defaultAPIPort
# bind_addr = "0.0.0.0" # default; set to "127.0.0.1" for localhost-only

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
if [ $# -ne 1 ]; then
echo "Usage: $0 <version> (e.g. 0.7.0)" >&2
exit 1
fi
VERSION="$1"
TAG="v$VERSION"
# Sanity checks
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "ERROR: working tree is dirty — commit or stash first" >&2
exit 1
fi
if [ "$(git branch --show-current)" != "main" ]; then
echo "ERROR: must be on main branch" >&2
exit 1
fi
if git tag -l "$TAG" | grep -q .; then
echo "ERROR: tag $TAG already exists" >&2
exit 1
fi
CURRENT=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "Bumping $CURRENT -> $VERSION"
# Bump version
sed -i.bak "s/^version = \"$CURRENT\"/version = \"$VERSION\"/" Cargo.toml
rm -f Cargo.toml.bak
cargo update --workspace
# Commit, tag, push
git add Cargo.toml Cargo.lock
git commit -m "chore: bump version to $VERSION"
git tag "$TAG"
git push origin main "$TAG"
echo
echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release."

View File

@@ -1,57 +0,0 @@
#!/usr/bin/env python3
"""Rewrite a Homebrew formula in place: bump version, URL paths, and sha256 lines.
Reads the formula path from argv[1], and the following env vars:
VERSION e.g. "0.10.0" (no leading v)
SHA_MACOS_AARCH64
SHA_MACOS_X86_64
SHA_LINUX_AARCH64
SHA_LINUX_X86_64
Assumptions about the formula:
- Has `version "X.Y.Z"` somewhere
- Has `url "...releases/download/vX.Y.Z/numa-<target>.tar.gz"` lines
- May or may not already have `sha256 "..."` lines immediately after each url
"""
import os
import re
import sys
formula_path = sys.argv[1]
version = os.environ["VERSION"].lstrip("v")
shas = {
"macos-aarch64": os.environ["SHA_MACOS_AARCH64"],
"macos-x86_64": os.environ["SHA_MACOS_X86_64"],
"linux-aarch64": os.environ["SHA_LINUX_AARCH64"],
"linux-x86_64": os.environ["SHA_LINUX_X86_64"],
}
with open(formula_path) as f:
content = f.read()
content = re.sub(r'version "[^"]*"', f'version "{version}"', content)
content = re.sub(
r"releases/download/v[\d.]+/numa-",
f"releases/download/v{version}/numa-",
content,
)
content = re.sub(r'\n[ \t]*sha256 "[^"]*"', "", content)
def add_sha(match: re.Match) -> str:
indent = match.group(1)
target = match.group(2)
if target not in shas:
return match.group(0)
return f'{match.group(0)}\n{indent}sha256 "{shas[target]}"'
content = re.sub(
r'^([ \t]+)url "[^"]*numa-([\w-]+)\.tar\.gz"',
add_sha,
content,
flags=re.MULTILINE,
)
with open(formula_path, "w") as f:
f.write(content)

View File

@@ -74,7 +74,6 @@ body::before {
font-weight: 400; font-weight: 400;
color: var(--text-primary); color: var(--text-primary);
text-decoration: none; text-decoration: none;
text-transform: none;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.blog-nav .wordmark:hover { color: var(--amber); } .blog-nav .wordmark:hover { color: var(--amber); }
@@ -298,7 +297,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

@@ -1,136 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 680" font-family="'DM Sans', system-ui, sans-serif" font-size="13">
<defs>
<marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"/>
</marker>
<marker id="arr-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c0623a"/>
</marker>
<marker id="arr-teal" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7c4e"/>
</marker>
<filter id="s" x="-3%" y="-3%" width="106%" height="106%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
</filter>
</defs>
<!-- Background -->
<rect width="720" height="680" rx="8" fill="#faf7f2"/>
<!-- Title -->
<text x="360" y="36" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">DNSSEC Chain of Trust</text>
<text x="360" y="54" text-anchor="middle" font-size="11" fill="#a39888">Verifying cloudflare.com — from answer to root trust anchor</text>
<!-- Legend -->
<g transform="translate(28, 72)">
<rect width="14" height="14" rx="3" fill="#c0623a" opacity="0.15" stroke="#c0623a" stroke-width="1"/>
<text x="20" y="12" font-size="11" fill="#6b5e4f">Verify signature (RRSIG → DNSKEY)</text>
<rect x="230" width="14" height="14" rx="3" fill="#6b7c4e" opacity="0.15" stroke="#6b7c4e" stroke-width="1"/>
<text x="250" y="12" font-size="11" fill="#6b5e4f">Vouch for key (DS → parent DNSKEY)</text>
<rect x="478" width="14" height="14" rx="3" fill="#2c2418" opacity="0.08" stroke="#2c2418" stroke-opacity="0.15" stroke-width="1"/>
<text x="498" y="12" font-size="11" fill="#6b5e4f">DNS record / key</text>
</g>
<!-- ═══ ZONE: cloudflare.com ═══ -->
<rect x="40" y="104" width="640" height="152" rx="8" fill="none" stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,3"/>
<text x="56" y="122" font-size="10" font-weight="600" fill="#a39888" letter-spacing="0.08em" font-family="'JetBrains Mono', monospace">CLOUDFLARE.COM ZONE</text>
<!-- A record -->
<rect x="80" y="138" width="320" height="38" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
<text x="96" y="157" font-size="12" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">cloudflare.com A 104.16.132.229</text>
<text x="96" y="170" font-size="10" fill="#a39888">The answer we want to verify</text>
<!-- RRSIG -->
<line x1="400" y1="157" x2="440" y2="157" stroke="#c0623a" stroke-width="1.5" marker-end="url(#arr-amber)"/>
<text x="412" y="149" font-size="9" fill="#c0623a" font-weight="600">signed by</text>
<rect x="445" y="138" width="220" height="38" rx="6" fill="rgba(192,98,58,0.06)" stroke="rgba(192,98,58,0.2)" filter="url(#s)"/>
<text x="461" y="155" font-size="11" font-weight="600" fill="#9e4e2d" font-family="'JetBrains Mono', monospace">RRSIG</text>
<text x="505" y="155" font-size="11" fill="#6b5e4f">tag=34505, algo=13</text>
<text x="461" y="170" font-size="10" fill="#a39888">signer: cloudflare.com</text>
<!-- DNSKEY -->
<rect x="80" y="192" width="320" height="50" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
<text x="96" y="211" font-size="11" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">DNSKEY</text>
<text x="156" y="211" font-size="11" fill="#6b5e4f">cloudflare.com, tag=34505</text>
<text x="96" y="228" font-size="11" fill="#6b7c4e" font-weight="500">ECDSA P-256</text>
<text x="194" y="228" font-size="10" fill="#a39888">— 174ns to verify</text>
<!-- RRSIG → DNSKEY arrow -->
<path d="M 555 176 L 555 192 L 400 192 L 400 200" stroke="#c0623a" stroke-width="1.5" fill="none" marker-end="url(#arr-amber)"/>
<text x="460" y="189" font-size="9" fill="#c0623a" font-weight="600">verified with</text>
<!-- ═══ ZONE: .com ═══ -->
<rect x="40" y="270" width="640" height="132" rx="8" fill="none" stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,3"/>
<text x="56" y="288" font-size="10" font-weight="600" fill="#a39888" letter-spacing="0.08em" font-family="'JetBrains Mono', monospace">.COM TLD ZONE</text>
<!-- DS connecting zones -->
<line x1="240" y1="242" x2="240" y2="302" stroke="#6b7c4e" stroke-width="1.5" marker-end="url(#arr-teal)"/>
<text x="252" y="276" font-size="9" fill="#6b7c4e" font-weight="600">vouched for by</text>
<!-- DS record at .com -->
<rect x="80" y="304" width="320" height="38" rx="6" fill="rgba(107,124,78,0.06)" stroke="rgba(107,124,78,0.2)" filter="url(#s)"/>
<text x="96" y="321" font-size="11" font-weight="600" fill="#566540" font-family="'JetBrains Mono', monospace">DS</text>
<text x="118" y="321" font-size="11" fill="#6b5e4f">tag=2371, digest=SHA-256</text>
<text x="96" y="336" font-size="10" fill="#a39888">hash of cloudflare.com DNSKEY</text>
<!-- DS signed by RRSIG -->
<line x1="400" y1="323" x2="440" y2="323" stroke="#c0623a" stroke-width="1.5" marker-end="url(#arr-amber)"/>
<text x="412" y="315" font-size="9" fill="#c0623a" font-weight="600">signed by</text>
<rect x="445" y="304" width="220" height="38" rx="6" fill="rgba(192,98,58,0.06)" stroke="rgba(192,98,58,0.2)" filter="url(#s)"/>
<text x="461" y="321" font-size="11" font-weight="600" fill="#9e4e2d" font-family="'JetBrains Mono', monospace">RRSIG</text>
<text x="505" y="321" font-size="11" fill="#6b5e4f">tag=19718, signer=com</text>
<!-- .com DNSKEY -->
<rect x="80" y="356" width="320" height="32" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
<text x="96" y="377" font-size="11" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">DNSKEY</text>
<text x="156" y="377" font-size="11" fill="#6b5e4f">com, tag=19718</text>
<!-- RRSIG → .com DNSKEY -->
<path d="M 555 342 L 555 356 L 400 356 L 400 366" stroke="#c0623a" stroke-width="1.5" fill="none" marker-end="url(#arr-amber)"/>
<text x="460" y="353" font-size="9" fill="#c0623a" font-weight="600">verified with</text>
<!-- ═══ ZONE: root ═══ -->
<rect x="40" y="404" width="640" height="132" rx="8" fill="none" stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,3"/>
<text x="56" y="422" font-size="10" font-weight="600" fill="#a39888" letter-spacing="0.08em" font-family="'JetBrains Mono', monospace">ROOT ZONE (.)</text>
<!-- DS connecting .com → root -->
<line x1="240" y1="388" x2="240" y2="436" stroke="#6b7c4e" stroke-width="1.5" marker-end="url(#arr-teal)"/>
<text x="252" y="416" font-size="9" fill="#6b7c4e" font-weight="600">vouched for by</text>
<!-- DS at root -->
<rect x="80" y="438" width="320" height="38" rx="6" fill="rgba(107,124,78,0.06)" stroke="rgba(107,124,78,0.2)" filter="url(#s)"/>
<text x="96" y="455" font-size="11" font-weight="600" fill="#566540" font-family="'JetBrains Mono', monospace">DS</text>
<text x="118" y="455" font-size="11" fill="#6b5e4f">tag=30909, digest=SHA-256</text>
<text x="96" y="470" font-size="10" fill="#a39888">hash of com DNSKEY</text>
<!-- DS signed by root RRSIG -->
<line x1="400" y1="457" x2="440" y2="457" stroke="#c0623a" stroke-width="1.5" marker-end="url(#arr-amber)"/>
<text x="412" y="449" font-size="9" fill="#c0623a" font-weight="600">signed by</text>
<rect x="445" y="438" width="220" height="38" rx="6" fill="rgba(192,98,58,0.06)" stroke="rgba(192,98,58,0.2)" filter="url(#s)"/>
<text x="461" y="455" font-size="11" font-weight="600" fill="#9e4e2d" font-family="'JetBrains Mono', monospace">RRSIG</text>
<text x="505" y="455" font-size="11" fill="#6b5e4f">signer=.</text>
<!-- Root DNSKEY -->
<rect x="80" y="490" width="320" height="32" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
<text x="96" y="511" font-size="11" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">DNSKEY</text>
<text x="156" y="511" font-size="11" fill="#6b5e4f">root (.), tag=20326, RSA/SHA-256</text>
<!-- RRSIG → root DNSKEY -->
<path d="M 555 476 L 555 490 L 400 490 L 400 500" stroke="#c0623a" stroke-width="1.5" fill="none" marker-end="url(#arr-amber)"/>
<text x="460" y="487" font-size="9" fill="#c0623a" font-weight="600">verified with</text>
<!-- ═══ TRUST ANCHOR ═══ -->
<line x1="240" y1="522" x2="240" y2="558" stroke="#2c2418" stroke-width="2" stroke-dasharray="4,3"/>
<rect x="120" y="560" width="480" height="52" rx="8" fill="#2c2418" filter="url(#s)"/>
<text x="360" y="582" text-anchor="middle" font-size="12" font-weight="600" fill="#faf7f2" font-family="'JetBrains Mono', monospace">ROOT TRUST ANCHOR</text>
<text x="360" y="600" text-anchor="middle" font-size="11" fill="#a39888">IANA KSK, key_tag=20326 — hardcoded in Numa as const [u8; 256]</text>
<!-- Flow summary -->
<text x="360" y="646" text-anchor="middle" font-size="12" fill="#6b5e4f" font-style="italic">Trust flows up (DS records). Keys flow down (DNSKEY → RRSIG).</text>
<text x="360" y="664" text-anchor="middle" font-size="11" fill="#a39888">If any link breaks — wrong signature, missing DS, expired RRSIG — Numa rejects the response.</text>
</svg>

Before

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -1,129 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 360" font-family="'DM Sans', system-ui, sans-serif" font-size="12">
<defs>
<marker id="arr-amber" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c0623a"/>
</marker>
<marker id="arr-dim" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#a39888"/>
</marker>
<filter id="shadow" x="-3%" y="-3%" width="106%" height="106%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
</filter>
</defs>
<!-- Background -->
<rect width="720" height="360" rx="8" fill="#faf7f2"/>
<!-- Title -->
<text x="360" y="32" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">UDP vs DoT — one lookup, three scenarios</text>
<text x="360" y="50" text-anchor="middle" font-size="11" fill="#a39888">Time flows downward. Amber = DNS work. Gray = TCP/TLS handshake overhead.</text>
<!-- ==================== Column 1: Plain UDP ==================== -->
<g transform="translate(20, 0)">
<!-- Column header -->
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">Plain UDP DNS</text>
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 53 · CLEARTEXT</text>
<!-- Lane labels -->
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
<!-- Lanes -->
<line x1="35" y1="138" x2="35" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<line x1="145" y1="138" x2="145" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<!-- query -->
<line x1="37" y1="148" x2="143" y2="160" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="143" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query</text>
<!-- response -->
<line x1="143" y1="178" x2="37" y2="190" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="205" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">response</text>
<!-- Total cost badge -->
<rect x="20" y="225" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
<text x="90" y="241" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
<text x="90" y="253" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">1 × RTT</text>
</g>
<!-- ==================== Column 2: DoT cold ==================== -->
<g transform="translate(270, 0)">
<!-- Column header -->
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">DoT — first query</text>
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 853 · NEW CONNECTION</text>
<!-- Lane labels -->
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
<!-- Lanes -->
<line x1="35" y1="138" x2="35" y2="308" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<line x1="145" y1="138" x2="145" y2="308" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<!-- === RTT 1: TCP handshake === -->
<!-- SYN -->
<line x1="37" y1="145" x2="143" y2="153" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- SYN-ACK -->
<line x1="143" y1="163" x2="37" y2="171" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- ACK -->
<line x1="37" y1="181" x2="143" y2="189" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- Label + RTT marker -->
<text x="168" y="170" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">1 rtt</text>
<text x="90" y="143" text-anchor="middle" font-size="9" fill="#6b5e4f" font-style="italic">TCP handshake</text>
<!-- === RTT 2: TLS 1.3 handshake === -->
<!-- ClientHello -->
<line x1="37" y1="208" x2="143" y2="216" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- ServerHello + Cert + Finished -->
<line x1="143" y1="226" x2="37" y2="234" stroke="#a39888" stroke-width="1.5" marker-end="url(#arr-dim)"/>
<!-- Label + RTT marker -->
<text x="168" y="222" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">2 rtt</text>
<text x="90" y="205" text-anchor="middle" font-size="9" fill="#6b5e4f" font-style="italic">TLS 1.3 handshake</text>
<!-- === RTT 3: DNS exchange === -->
<!-- query (piggybacked on ClientFinished) -->
<line x1="37" y1="253" x2="143" y2="261" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<!-- response -->
<line x1="143" y1="271" x2="37" y2="279" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<!-- Label + RTT marker -->
<text x="168" y="267" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">3 rtt</text>
<text x="90" y="250" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query + response</text>
<!-- Total cost badge -->
<rect x="20" y="295" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
<text x="90" y="311" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
<text x="90" y="323" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">3 × RTT</text>
</g>
<!-- ==================== Column 3: DoT reused ==================== -->
<g transform="translate(520, 0)">
<!-- Column header -->
<text x="90" y="84" text-anchor="middle" font-size="13" font-weight="600" fill="#2c2418">DoT — reused session</text>
<text x="90" y="101" text-anchor="middle" font-size="10" fill="#a39888" letter-spacing="0.06em">PORT 853 · PERSISTENT TCP/TLS</text>
<!-- Lane labels -->
<text x="25" y="128" font-size="10" fill="#6b5e4f">client</text>
<text x="133" y="128" font-size="10" fill="#6b5e4f">server</text>
<!-- Lanes -->
<line x1="35" y1="138" x2="35" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<line x1="145" y1="138" x2="145" y2="198" stroke="#d4cbba" stroke-width="1" stroke-dasharray="2 3"/>
<!-- query -->
<line x1="37" y1="148" x2="143" y2="160" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="143" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">query</text>
<!-- response -->
<line x1="143" y1="178" x2="37" y2="190" stroke="#c0623a" stroke-width="2" marker-end="url(#arr-amber)"/>
<text x="90" y="205" text-anchor="middle" font-size="10" fill="#9e4e2d" font-weight="500">response</text>
<!-- Total cost badge -->
<rect x="20" y="225" width="140" height="32" rx="4" fill="#faf7f2" stroke="#d4cbba" stroke-width="1" filter="url(#shadow)"/>
<text x="90" y="241" text-anchor="middle" font-size="9" fill="#a39888" letter-spacing="0.04em">TOTAL LATENCY</text>
<text x="90" y="253" text-anchor="middle" font-size="11" font-weight="600" fill="#c0623a" font-family="'JetBrains Mono', monospace">1 × RTT</text>
<!-- Tiny caption -->
<text x="90" y="280" text-anchor="middle" font-size="9" fill="#a39888" font-style="italic">(handshake amortized</text>
<text x="90" y="292" text-anchor="middle" font-size="9" fill="#a39888" font-style="italic">across the session)</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -1,92 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 330" font-family="'DM Sans', system-ui, sans-serif" font-size="12">
<defs>
<filter id="shadow" x="-3%" y="-3%" width="106%" height="106%">
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
</filter>
<!-- Diagonal hatch for "wasted" UDP timeout regions. Darker warm gray
base + slightly darker diagonal stripes at 45°. The stripe pattern
is the Gantt convention for "dead/blocked time" — it reads as
"this time was thrown away" without needing the legend. -->
<pattern id="wasted-hatch" patternUnits="userSpaceOnUse" width="7" height="7" patternTransform="rotate(-45)">
<rect width="7" height="7" fill="#8b7f6f"/>
<line x1="0" y1="0" x2="0" y2="7" stroke="#3d3427" stroke-width="1.6" opacity="0.38"/>
</pattern>
</defs>
<!-- Background -->
<rect width="720" height="330" rx="8" fill="#faf7f2"/>
<!-- Title -->
<text x="360" y="32" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">TCP fallback with UDP auto-disable</text>
<text x="360" y="50" text-anchor="middle" font-size="11" fill="#a39888">Latency profile on an ISP that blocks outbound UDP:53</text>
<!-- Legend -->
<g transform="translate(160, 70)">
<rect width="14" height="12" rx="2" fill="url(#wasted-hatch)"/>
<text x="22" y="10" font-size="11" fill="#6b5e4f">UDP timeout — 800 ms wasted</text>
<rect x="220" width="14" height="12" rx="2" fill="#c0623a"/>
<text x="242" y="10" font-size="11" fill="#6b5e4f">TCP — successful exchange</text>
</g>
<!-- Time axis -->
<!-- bar area: x=90 to x=570 (480px), representing 0-1200ms, scale 0.4 px/ms -->
<line x1="90" y1="108" x2="570" y2="108" stroke="#d4cbba" stroke-width="1"/>
<!-- tick marks -->
<line x1="90" y1="106" x2="90" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="210" y1="106" x2="210" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="330" y1="106" x2="330" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="410" y1="106" x2="410" y2="112" stroke="#a39888" stroke-width="1"/>
<line x1="530" y1="106" x2="530" y2="112" stroke="#a39888" stroke-width="1"/>
<!-- tick labels -->
<text x="90" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">0</text>
<text x="210" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">300</text>
<text x="330" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">600</text>
<text x="410" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">800</text>
<text x="530" y="102" text-anchor="middle" font-size="9" fill="#a39888" font-family="'JetBrains Mono', monospace">1100 ms</text>
<!-- ============ Phase 1: UDP-first (wasted 800ms per query) ============ -->
<!-- Query 1 -->
<text x="82" y="135" text-anchor="end" font-size="11" fill="#6b5e4f">query 1</text>
<rect x="90" y="125" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
<rect x="410" y="125" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="540" y="137" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
<!-- Query 2 -->
<text x="82" y="159" text-anchor="end" font-size="11" fill="#6b5e4f">query 2</text>
<rect x="90" y="149" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
<rect x="410" y="149" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="540" y="161" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
<!-- Query 3 -->
<text x="82" y="183" text-anchor="end" font-size="11" fill="#6b5e4f">query 3</text>
<rect x="90" y="173" width="320" height="16" rx="2" fill="url(#wasted-hatch)"/>
<rect x="410" y="173" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="540" y="185" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">1,100 ms</text>
<!-- State-change divider -->
<line x1="90" y1="206" x2="570" y2="206" stroke="#6b7c4e" stroke-width="1" stroke-dasharray="4 3"/>
<rect x="200" y="198" width="260" height="18" rx="9" fill="#faf7f2" stroke="#6b7c4e" stroke-width="1" filter="url(#shadow)"/>
<text x="330" y="210" text-anchor="middle" font-size="10" fill="#566540" font-weight="500">3 consecutive failures → UDP auto-disabled</text>
<!-- ============ Phase 2: TCP-first (UDP skipped) ============ -->
<!-- Query 4 -->
<text x="82" y="235" text-anchor="end" font-size="11" fill="#6b5e4f">query 4</text>
<rect x="90" y="225" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="220" y="237" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">300 ms</text>
<!-- Query 5 -->
<text x="82" y="259" text-anchor="end" font-size="11" fill="#6b5e4f">query 5</text>
<rect x="90" y="249" width="120" height="16" rx="2" fill="#c0623a"/>
<text x="220" y="261" font-size="10" fill="#6b5e4f" font-family="'JetBrains Mono', monospace">300 ms</text>
<!-- Speedup callout -->
<g transform="translate(300, 246)">
<line x1="0" y1="-10" x2="0" y2="22" stroke="#6b7c4e" stroke-width="1" stroke-dasharray="2 2"/>
<text x="10" y="6" font-size="10" fill="#566540" font-style="italic">3.7× faster — no more UDP wait</text>
</g>
<!-- Footer caption -->
<text x="360" y="298" text-anchor="middle" font-size="10" fill="#a39888" font-style="italic">The flag resets on network change (LAN IP delta). Switch back to a clean network and UDP is tried again.</text>
</svg>

Before

Width:  |  Height:  |  Size: 5.6 KiB

View File

@@ -67,7 +67,6 @@ body::before {
font-weight: 400; font-weight: 400;
color: var(--text-primary); color: var(--text-primary);
text-decoration: none; text-decoration: none;
text-transform: none;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
.blog-nav .wordmark:hover { color: var(--amber); } .blog-nav .wordmark:hover { color: var(--amber); }
@@ -168,13 +167,6 @@ body::before {
<main class="blog-index"> <main class="blog-index">
<h1>Blog</h1> <h1>Blog</h1>
<ul class="post-list"> <ul class="post-list">
<li>
<a href="/blog/posts/dot-from-scratch.html">
<div class="post-title">DNS-over-TLS from Scratch in Rust</div>
<div class="post-desc">Building RFC 7858 on top of rustls — length-prefix framing, ALPN cross-protocol defense, iPhone dogfooding, and two bugs that only the strict clients caught.</div>
<div class="post-date">April 2026</div>
</a>
</li>
<li> <li>
<a href="/blog/posts/dnssec-from-scratch.html"> <a href="/blog/posts/dnssec-from-scratch.html">
<div class="post-title">Implementing DNSSEC from Scratch in Rust</div> <div class="post-title">Implementing DNSSEC from Scratch in Rust</div>
@@ -197,7 +189,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>

View File

@@ -101,7 +101,7 @@ body {
/* Stat cards row */ /* Stat cards row */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(6, 1fr); grid-template-columns: repeat(5, 1fr);
gap: 1rem; gap: 1rem;
} }
.stat-card { .stat-card {
@@ -125,8 +125,6 @@ body {
.stat-card.blocked::before { background: var(--rose); } .stat-card.blocked::before { background: var(--rose); }
.stat-card.overrides::before { background: var(--violet); } .stat-card.overrides::before { background: var(--violet); }
.stat-card.uptime::before { background: var(--cyan); } .stat-card.uptime::before { background: var(--cyan); }
.stat-card.memory::before { background: var(--text-dim); }
.stat-card.memory .stat-value { color: var(--text-secondary); }
.stat-label { .stat-label {
font-size: 0.7rem; font-size: 0.7rem;
@@ -287,8 +285,6 @@ body {
.path-tag.OVERRIDE { background: rgba(82, 122, 82, 0.12); color: var(--emerald); } .path-tag.OVERRIDE { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
.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); }
.src-tag { font-size: 0.6rem; color: var(--text-dim); letter-spacing: 0.02em; }
/* Sidebar panels */ /* Sidebar panels */
.sidebar { .sidebar {
@@ -471,74 +467,10 @@ body {
display: none; display: none;
} }
/* Memory sidebar panel */
.memory-bar {
display: flex;
height: 18px;
border-radius: 4px;
overflow: hidden;
background: var(--bg-surface);
margin-bottom: 0.8rem;
}
.memory-bar-seg {
height: 100%;
min-width: 2px;
transition: width 0.6s ease;
}
.memory-bar-seg.cache { background: var(--teal); }
.memory-bar-seg.blocklist { background: var(--rose); }
.memory-bar-seg.querylog { background: var(--amber); }
.memory-bar-seg.srtt { background: var(--cyan); }
.memory-bar-seg.overrides { background: var(--violet); }
.memory-row {
display: flex;
align-items: center;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.memory-row:last-child { border-bottom: none; }
.memory-row-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
margin-right: 0.5rem;
}
.memory-row-label {
flex: 1;
color: var(--text-secondary);
}
.memory-row-size {
width: 65px;
text-align: right;
color: var(--text-primary);
font-weight: 500;
}
.memory-row-entries {
width: 90px;
text-align: right;
color: var(--text-dim);
}
.memory-rss {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
/* Responsive */ /* Responsive */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; } .main-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) { @media (max-width: 700px) {
.stats-row { grid-template-columns: repeat(2, 1fr); } .stats-row { grid-template-columns: repeat(2, 1fr); }
.dashboard { padding: 1rem; } .dashboard { padding: 1rem; }
@@ -591,11 +523,6 @@ body {
<div class="stat-value" id="uptime"></div> <div class="stat-value" id="uptime"></div>
<div class="stat-sub" id="uptimeSub">&nbsp;</div> <div class="stat-sub" id="uptimeSub">&nbsp;</div>
</div> </div>
<div class="stat-card memory">
<div class="stat-label">Memory</div>
<div class="stat-value" id="memoryRss"></div>
<div class="stat-sub" id="memorySub">&nbsp;</div>
</div>
</div> </div>
<!-- Resolution paths --> <!-- Resolution paths -->
@@ -620,8 +547,6 @@ body {
<select id="logFilterPath" onchange="applyLogFilter()" <select id="logFilterPath" 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;"> 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 paths</option> <option value="">all paths</option>
<option value="RECURSIVE">recursive</option>
<option value="COALESCED">coalesced</option>
<option value="FORWARD">forward</option> <option value="FORWARD">forward</option>
<option value="CACHED">cached</option> <option value="CACHED">cached</option>
<option value="BLOCKED">blocked</option> <option value="BLOCKED">blocked</option>
@@ -720,17 +645,6 @@ body {
</div> </div>
</div> </div>
<!-- Memory breakdown -->
<div class="panel" id="memoryPanel">
<div class="panel-header">
<span class="panel-title">Memory</span>
<span class="panel-title" id="memoryTotal" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="memoryBody">
<div class="empty-state">No memory data</div>
</div>
</div>
<!-- Cache entries --> <!-- Cache entries -->
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -788,13 +702,6 @@ function formatTime(epoch) {
return d.toLocaleTimeString([], { hour12: false }); return d.toLocaleTimeString([], { hour12: false });
} }
function shortSrc(addr) {
if (!addr) return '';
const ip = addr.replace(/:\d+$/, '');
if (ip === '127.0.0.1' || ip === '::1') return 'localhost';
return ip;
}
function formatRemaining(secs) { function formatRemaining(secs) {
if (secs == null) return 'permanent'; if (secs == null) return 'permanent';
if (secs < 60) return `${secs}s left`; if (secs < 60) return `${secs}s left`;
@@ -802,69 +709,6 @@ function formatRemaining(secs) {
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`; return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`;
} }
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
const MEMORY_COMPONENTS = [
{ key: 'cache', label: 'Cache', cls: 'cache', color: 'var(--teal)' },
{ key: 'blocklist', label: 'Blocklist', cls: 'blocklist', color: 'var(--rose)' },
{ key: 'query_log', label: 'Query Log', cls: 'querylog', color: 'var(--amber)' },
{ key: 'srtt', label: 'SRTT', cls: 'srtt', color: 'var(--cyan)' },
{ key: 'overrides', label: 'Overrides', cls: 'overrides', color: 'var(--violet)' },
];
function renderMemory(mem, stats) {
if (!mem) return;
// Stat card
document.getElementById('memoryRss').textContent = formatBytes(mem.process_memory_bytes);
document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes);
const entryCounts = {
cache: stats.cache.entries,
blocklist: stats.blocking.domains_loaded,
query_log: mem.query_log_entries,
srtt: mem.srtt_entries,
overrides: stats.overrides.active,
};
// Sidebar panel
const total = mem.total_estimated_bytes || 1;
document.getElementById('memoryTotal').textContent = formatBytes(total);
const barSegments = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const pct = ((bytes / total) * 100).toFixed(1);
return `<div class="memory-bar-seg ${c.cls}" style="width:${pct}%" title="${c.label}: ${formatBytes(bytes)} (${pct}%)"></div>`;
}).join('');
const rows = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const entries = entryCounts[c.key] || 0;
return `
<div class="memory-row">
<div class="memory-row-dot" style="background:${c.color}"></div>
<span class="memory-row-label">${c.label}</span>
<span class="memory-row-size">${formatBytes(bytes)}</span>
<span class="memory-row-entries">${formatNumber(entries)} entries</span>
</div>`;
}).join('');
document.getElementById('memoryBody').innerHTML = `
<div class="memory-bar">${barSegments}</div>
${rows}
<div class="memory-rss">
<span>Process Footprint</span>
<span>${formatBytes(mem.process_memory_bytes)}</span>
</div>
`;
}
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' },
@@ -920,8 +764,8 @@ function applyLogFilter() {
? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>` ? ` <button class="btn-delete" onclick="allowDomain('${e.domain}')" title="Allow this domain" style="color:var(--emerald);font-size:0.65rem;">allow</button>`
: ''; : '';
return ` return `
<tr title="Source: ${e.src || 'unknown'}"> <tr>
<td>${formatTime(e.timestamp_epoch)}<br><span class="src-tag">${shortSrc(e.src)}</span></td> <td>${formatTime(e.timestamp_epoch)}</td>
<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>
@@ -1035,9 +879,6 @@ async function refresh() {
document.getElementById('footerUpstream').textContent = stats.upstream || ''; document.getElementById('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || ''; document.getElementById('footerConfig').textContent = stats.config_path || '';
document.getElementById('footerData').textContent = stats.data_dir || ''; document.getElementById('footerData').textContent = stats.data_dir || '';
const modeEl = document.getElementById('footerMode');
modeEl.textContent = stats.mode || '—';
modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)';
document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off'; document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off';
document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)'; document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
@@ -1101,7 +942,7 @@ async function refresh() {
prevTime = now; prevTime = now;
// Cache hit rate // Cache hit rate
const answered = q.cached + q.forwarded + q.recursive + q.coalesced + q.local + q.overridden; const answered = q.cached + q.forwarded + q.local + q.overridden;
const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0'; const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0';
document.getElementById('cacheRate').textContent = hitRate + '%'; document.getElementById('cacheRate').textContent = hitRate + '%';
@@ -1113,7 +954,6 @@ async function refresh() {
renderServices(services); renderServices(services);
renderBlockingInfo(blockingInfo); renderBlockingInfo(blockingInfo);
renderAllowlist(allowlist); renderAllowlist(allowlist);
renderMemory(stats.memory, stats);
} catch (err) { } catch (err) {
document.getElementById('statusDot').className = 'status-dot error'; document.getElementById('statusDot').className = 'status-dot error';
@@ -1393,7 +1233,6 @@ setInterval(refresh, 2000);
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span> Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span> · Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span> · Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
· Mode: <span id="footerMode" style="color:var(--text-dim);"></span>
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span> · DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span>
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span> · SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span>
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span> · Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Numa — DNS you own. Everywhere you go.</title> <title>Numa — DNS you own. Everywhere you go.</title>
<meta name="description" content="DNS you own. Portable DNS resolver with caching, ad blocking, .numa local domains, developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust."> <meta name="description" content="DNS you own. Recursive resolver with full DNSSEC validation, ad blocking, .numa local domains, developer overrides. A single portable binary built from scratch in Rust.">
<link rel="canonical" href="https://numa.rs"> <link rel="canonical" href="https://numa.rs">
<meta property="og:title" content="Numa — DNS you own. Everywhere you go."> <meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
<meta property="og:description" content="Portable DNS resolver with caching, ad blocking, .numa local domains, and developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust."> <meta property="og:description" content="Recursive DNS resolver with full DNSSEC validation, ad blocking, .numa local domains, and developer overrides. Built from scratch in Rust.">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:url" content="https://numa.rs"> <meta property="og:url" content="https://numa.rs">
<link rel="stylesheet" href="/fonts/fonts.css"> <link rel="stylesheet" href="/fonts/fonts.css">
@@ -188,50 +188,11 @@ p.lead {
line-height: 1.8; line-height: 1.8;
} }
/* ===========================
TOP NAV
=========================== */
.site-nav {
padding: 1.5rem 2rem;
display: flex;
align-items: center;
gap: 1.5rem;
position: relative;
z-index: 10;
}
.site-nav a {
font-family: var(--font-mono);
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-dim);
text-decoration: none;
transition: color 0.2s ease;
}
.site-nav a:hover { color: var(--amber); }
.site-nav .wordmark {
font-family: var(--font-display);
font-size: 1.4rem;
font-weight: 400;
color: var(--text-primary);
text-transform: none;
letter-spacing: -0.02em;
}
.site-nav .wordmark:hover { color: var(--amber); }
.site-nav .sep {
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 0.75rem;
}
/* =========================== /* ===========================
HERO HERO
=========================== */ =========================== */
.hero { .hero {
min-height: calc(100vh - 5rem); min-height: 100vh;
display: flex; display: flex;
align-items: center; align-items: center;
position: relative; position: relative;
@@ -1197,9 +1158,6 @@ footer .closing {
@media (max-width: 600px) { @media (max-width: 600px) {
section { padding: 4rem 0; } section { padding: 4rem 0; }
.container { padding: 0 1.25rem; } .container { padding: 0 1.25rem; }
.site-nav { padding: 1rem 1.25rem; gap: 1rem; }
.site-nav .wordmark { font-size: 1.2rem; }
.hero { min-height: calc(100vh - 4rem); }
.network-grid { grid-template-columns: 1fr; } .network-grid { grid-template-columns: 1fr; }
.pipeline { flex-direction: column; align-items: stretch; gap: 0; } .pipeline { flex-direction: column; align-items: stretch; gap: 0; }
.pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; } .pipeline-arrow { transform: rotate(90deg); padding: 0.15rem 0; align-self: center; }
@@ -1213,14 +1171,6 @@ footer .closing {
</head> </head>
<body> <body>
<nav class="site-nav">
<a href="/" class="wordmark">Numa</a>
<span class="sep">/</span>
<a href="/blog/">Blog</a>
<span class="sep">/</span>
<a href="https://github.com/razvandimescu/numa" target="_blank" rel="noopener">GitHub</a>
</nav>
<!-- ==================== HERO ==================== --> <!-- ==================== HERO ==================== -->
<section class="hero"> <section class="hero">
<div class="roman-bricks" aria-hidden="true"></div> <div class="roman-bricks" aria-hidden="true"></div>
@@ -1282,19 +1232,17 @@ footer .closing {
<div class="reveal"> <div class="reveal">
<div class="section-label">How It Works</div> <div class="section-label">How It Works</div>
<h2>What it does today</h2> <h2>What it does today</h2>
<p class="lead">A DNS resolver with caching, ad blocking, local service domains, and a REST API. Optional recursive resolution with DNSSEC. Everything runs in a single binary.</p> <p class="lead">A recursive DNS resolver with DNSSEC validation, ad blocking, local service domains, and a REST API. Everything runs in a single binary.</p>
</div> </div>
<div class="layers-grid"> <div class="layers-grid">
<div class="layer-card reveal reveal-delay-1"> <div class="layer-card reveal reveal-delay-1">
<div class="layer-badge">Layer 1</div> <div class="layer-badge">Layer 1</div>
<h3>Resolve &amp; Protect</h3> <h3>Resolve &amp; Protect</h3>
<ul> <ul>
<li>Forward mode by default &mdash; transparent proxy to your existing DNS, with caching</li> <li>Recursive resolution &mdash; resolve from root nameservers, no upstream needed</li>
<li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</li>
<li>Recursive resolution &mdash; opt-in, resolve from root nameservers, no upstream needed</li>
<li>DNSSEC validation &mdash; chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li> <li>DNSSEC validation &mdash; chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
<li>DNS-over-TLS listener &mdash; encrypted DNS for phones and strict clients (RFC 7858 with ALPN defense)</li> <li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</li>
<li>Hostile-network resilience &mdash; TCP fallback with UDP auto-disable when ISPs block port 53</li> <li>DNS-over-HTTPS &mdash; encrypted upstream as alternative to recursive mode</li>
<li>TTL-aware caching (sub-ms lookups)</li> <li>TTL-aware caching (sub-ms lookups)</li>
<li>Single binary, portable &mdash; macOS, Linux, and Windows</li> <li>Single binary, portable &mdash; macOS, Linux, and Windows</li>
</ul> </ul>
@@ -1313,7 +1261,7 @@ footer .closing {
</ul> </ul>
</div> </div>
<div class="layer-card reveal reveal-delay-3"> <div class="layer-card reveal reveal-delay-3">
<div class="layer-badge">The Vision</div> <div class="layer-badge">Coming Next</div>
<h3>Self-Sovereign DNS</h3> <h3>Self-Sovereign DNS</h3>
<ul> <ul>
<li>pkarr integration &mdash; DNS via Mainline DHT, no registrar needed</li> <li>pkarr integration &mdash; DNS via Mainline DHT, no registrar needed</li>
@@ -1394,14 +1342,6 @@ footer .closing {
<td class="cross">No</td> <td class="cross">No</td>
<td class="check">Root hints + full DNSSEC</td> <td class="check">Root hints + full DNSSEC</td>
</tr> </tr>
<tr>
<td>DNSSEC validation</td>
<td class="muted">Passthrough</td>
<td class="muted">Cloud only</td>
<td class="muted">Cloud only</td>
<td class="muted">Passthrough</td>
<td class="check">Full chain-of-trust</td>
</tr>
<tr> <tr>
<td>Ad &amp; tracker blocking</td> <td>Ad &amp; tracker blocking</td>
<td class="check">Yes</td> <td class="check">Yes</td>
@@ -1458,14 +1398,6 @@ footer .closing {
<td class="cross">No</td> <td class="cross">No</td>
<td class="check">Built in (HTTP/2 + rustls)</td> <td class="check">Built in (HTTP/2 + rustls)</td>
</tr> </tr>
<tr>
<td>DNS-over-TLS listener</td>
<td class="cross">No</td>
<td class="muted">Cloud only</td>
<td class="muted">Cloud only</td>
<td class="check">Yes (cert required)</td>
<td class="check">Self-signed or BYO</td>
</tr>
<tr> <tr>
<td>Conditional forwarding</td> <td>Conditional forwarding</td>
<td class="cross">No</td> <td class="cross">No</td>
@@ -1635,14 +1567,11 @@ footer .closing {
<dt>Resolution Modes</dt> <dt>Resolution Modes</dt>
<dd>Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)</dd> <dd>Recursive (iterative from root hints, CNAME chasing, glue extraction) or Forward (DoH / plain UDP)</dd>
<dt>Listeners</dt>
<dd>UDP:53 + TCP:53 (plain DNS), DoT:853 (RFC 7858 + ALPN), HTTP proxy :80 / HTTPS proxy :443, dashboard :5380</dd>
<dt>DNSSEC</dt> <dt>DNSSEC</dt>
<dd>Chain-of-trust via ring &mdash; RSA/SHA-256, ECDSA P-256, Ed25519. NSEC/NSEC3 denial proofs. EDNS0 DO bit, 1232-byte payload (DNS Flag Day 2020).</dd> <dd>Chain-of-trust via ring &mdash; RSA/SHA-256, ECDSA P-256, Ed25519. NSEC/NSEC3 denial proofs. EDNS0 DO bit, 1232-byte payload (DNS Flag Day 2020).</dd>
<dt>Dependencies</dt> <dt>Dependencies</dt>
<dd>A focused set &mdash; tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls + tokio-rustls (TLS/DoT), socket2 (multicast), serde. No transitive DNS library.</dd> <dd>19 runtime crates &mdash; tokio, axum, hyper, ring (DNSSEC), reqwest (DoH), rcgen + rustls (TLS), socket2 (multicast), serde, and more</dd>
<dt>Packet Format</dt> <dt>Packet Format</dt>
<dd>RFC 1035 compliant. EDNS0 OPT pseudo-record. Parses A, AAAA, NS, CNAME, MX, SOA, SRV, HTTPS, DNSKEY, DS, RRSIG, NSEC, NSEC3.</dd> <dd>RFC 1035 compliant. EDNS0 OPT pseudo-record. Parses A, AAAA, NS, CNAME, MX, SOA, SRV, HTTPS, DNSKEY, DS, RRSIG, NSEC, NSEC3.</dd>
@@ -1657,7 +1586,7 @@ footer .closing {
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-fsSL</span> https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh <span class="flag">|</span> <span class="cmd">sh</span> <span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-fsSL</span> https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh <span class="flag">|</span> <span class="cmd">sh</span>
<span class="comment"># Run</span> <span class="comment"># Run</span>
<span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind :53, :80, :443, :853, :5380</span> <span class="prompt">$</span> <span class="cmd">sudo numa</span> <span class="comment"># bind to :53, :80, :5380</span>
<span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span> <span class="prompt">$</span> <span class="cmd">dig</span> <span class="flag">@127.0.0.1</span> google.com <span class="comment"># test resolution</span>
<span class="prompt">$</span> <span class="cmd">open</span> http://localhost:5380 <span class="comment"># dashboard</span> <span class="prompt">$</span> <span class="cmd">open</span> http://localhost:5380 <span class="comment"># dashboard</span>
<span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/services \ <span class="prompt">$</span> <span class="cmd">curl</span> <span class="flag">-X POST</span> localhost:5380/services \
@@ -1710,28 +1639,16 @@ footer .closing {
<span class="phase">Phase 7</span> <span class="phase">Phase 7</span>
<span class="phase-desc">DNSSEC validation &mdash; chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span> <span class="phase-desc">DNSSEC validation &mdash; chain-of-trust, NSEC/NSEC3 denial proofs, RSA + ECDSA + Ed25519</span>
</div> </div>
<div class="roadmap-item done">
<span class="phase">Phase 8</span>
<span class="phase-desc">Hostile-network resilience &mdash; TCP fallback with UDP auto-disable when ISPs block :53, RFC 7816 query minimization</span>
</div>
<div class="roadmap-item done">
<span class="phase">Phase 9</span>
<span class="phase-desc">Windows support &mdash; cross-platform install/uninstall, <code>netsh</code> DNS config, service integration</span>
</div>
<div class="roadmap-item done">
<span class="phase">Phase 10</span>
<span class="phase-desc">DNS-over-TLS listener (RFC 7858) &mdash; ALPN enforcement, persistent connections, self-signed or BYO cert</span>
</div>
<div class="roadmap-item phase-teal"> <div class="roadmap-item phase-teal">
<span class="phase">Phase 11</span> <span class="phase">Phase 8</span>
<span class="phase-desc">pkarr integration &mdash; self-sovereign DNS via Mainline DHT, no registrar needed</span> <span class="phase-desc">pkarr integration &mdash; self-sovereign DNS via Mainline DHT, no registrar needed</span>
</div> </div>
<div class="roadmap-item phase-teal"> <div class="roadmap-item phase-teal">
<span class="phase">Phase 12</span> <span class="phase">Phase 9</span>
<span class="phase-desc">Global .numa names &mdash; self-publish, DHT-backed, first-come-first-served</span> <span class="phase-desc">Global .numa names &mdash; self-publish, DHT-backed, first-come-first-served</span>
</div> </div>
<div class="roadmap-item phase-teal"> <div class="roadmap-item phase-teal">
<span class="phase">Phase 13</span> <span class="phase">Phase 10</span>
<span class="phase-desc">.onion bridge &mdash; human-readable Tor naming via Ed25519 same-key binding</span> <span class="phase-desc">.onion bridge &mdash; human-readable Tor naming via Ed25519 same-key binding</span>
</div> </div>
</div> </div>
@@ -1769,7 +1686,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

@@ -160,7 +160,6 @@ struct QueryLogResponse {
struct StatsResponse { struct StatsResponse {
uptime_secs: u64, uptime_secs: u64,
upstream: String, upstream: String,
mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
config_path: String, config_path: String,
data_dir: String, data_dir: String,
dnssec: bool, dnssec: bool,
@@ -170,7 +169,6 @@ struct StatsResponse {
overrides: OverrideStats, overrides: OverrideStats,
blocking: BlockingStatsResponse, blocking: BlockingStatsResponse,
lan: LanStatsResponse, lan: LanStatsResponse,
memory: MemoryStats,
} }
#[derive(Serialize)] #[derive(Serialize)]
@@ -184,7 +182,6 @@ struct QueriesStats {
total: u64, total: u64,
forwarded: u64, forwarded: u64,
recursive: u64, recursive: u64,
coalesced: u64,
cached: u64, cached: u64,
local: u64, local: u64,
overridden: u64, overridden: u64,
@@ -211,19 +208,6 @@ struct BlockingStatsResponse {
allowlist_size: usize, allowlist_size: usize,
} }
#[derive(Serialize)]
struct MemoryStats {
cache_bytes: usize,
blocklist_bytes: usize,
query_log_bytes: usize,
query_log_entries: usize,
srtt_bytes: usize,
srtt_entries: usize,
overrides_bytes: usize,
total_estimated_bytes: usize,
process_memory_bytes: usize,
}
#[derive(Serialize)] #[derive(Serialize)]
struct DiagnoseResponse { struct DiagnoseResponse {
domain: String, domain: String,
@@ -425,8 +409,14 @@ async fn forward_query_for_diagnose(
timeout: std::time::Duration, timeout: std::time::Duration,
) -> (bool, String) { ) -> (bool, String) {
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::question::DnsQuestion;
let query = DnsPacket::query(0xBEEF, domain, QueryType::A); let mut query = DnsPacket::new();
query.header.id = 0xBEEF;
query.header.recursion_desired = true;
query
.questions
.push(DnsQuestion::new(domain.to_string(), QueryType::A));
match forward_query(&query, upstream, timeout).await { match forward_query(&query, upstream, timeout).await {
Ok(resp) => ( Ok(resp) => (
@@ -485,29 +475,12 @@ async fn query_log(
async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> { async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
let snap = ctx.stats.lock().unwrap().snapshot(); let snap = ctx.stats.lock().unwrap().snapshot();
let (cache_len, cache_max, cache_bytes) = { let (cache_len, cache_max) = {
let cache = ctx.cache.read().unwrap(); let cache = ctx.cache.read().unwrap();
(cache.len(), cache.max_entries(), cache.heap_bytes()) (cache.len(), cache.max_entries())
}; };
let (override_count, overrides_bytes) = { let override_count = ctx.overrides.read().unwrap().active_count();
let ov = ctx.overrides.read().unwrap(); let bl_stats = ctx.blocklist.read().unwrap().stats();
(ov.active_count(), ov.heap_bytes())
};
let (bl_stats, blocklist_bytes) = {
let bl = ctx.blocklist.read().unwrap();
(bl.stats(), bl.heap_bytes())
};
let (query_log_bytes, query_log_entries) = {
let log = ctx.query_log.lock().unwrap();
(log.heap_bytes(), log.len())
};
let (srtt_bytes, srtt_entries, srtt_enabled) = {
let s = ctx.srtt.read().unwrap();
(s.heap_bytes(), s.len(), s.is_enabled())
};
let total_estimated =
cache_bytes + blocklist_bytes + query_log_bytes + srtt_bytes + overrides_bytes;
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()
@@ -518,16 +491,14 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
Json(StatsResponse { Json(StatsResponse {
uptime_secs: snap.uptime_secs, uptime_secs: snap.uptime_secs,
upstream, upstream,
mode: ctx.upstream_mode.as_str(),
config_path: ctx.config_path.clone(), config_path: ctx.config_path.clone(),
data_dir: ctx.data_dir.to_string_lossy().to_string(), data_dir: ctx.data_dir.to_string_lossy().to_string(),
dnssec: ctx.dnssec_enabled, dnssec: ctx.dnssec_enabled,
srtt: srtt_enabled, srtt: ctx.srtt.read().unwrap().is_enabled(),
queries: QueriesStats { queries: QueriesStats {
total: snap.total, total: snap.total,
forwarded: snap.forwarded, forwarded: snap.forwarded,
recursive: snap.recursive, recursive: snap.recursive,
coalesced: snap.coalesced,
cached: snap.cached, cached: snap.cached,
local: snap.local, local: snap.local,
overridden: snap.overridden, overridden: snap.overridden,
@@ -551,17 +522,6 @@ 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(),
}, },
memory: MemoryStats {
cache_bytes,
blocklist_bytes,
query_log_bytes,
query_log_entries,
srtt_bytes,
srtt_entries,
overrides_bytes,
total_estimated_bytes: total_estimated,
process_memory_bytes: crate::stats::process_memory_bytes(),
},
}) })
} }
@@ -592,19 +552,8 @@ async fn flush_cache_domain(
StatusCode::NO_CONTENT StatusCode::NO_CONTENT
} }
/// Enriched `/health` handler shared between the main API and the mobile API. async fn health() -> Json<serde_json::Value> {
/// Json(serde_json::json!({ "status": "ok" }))
/// Returns the cached `HealthMeta` assembled with live fields (LAN IP,
/// uptime). Backward compatible with the previous minimal response in
/// that `status` is still the first field and `"ok"` is still the value.
/// The iOS companion app's `HealthInfo` Swift struct decodes the full
/// response; any HTTP client asserting only on `"status"` keeps working.
pub async fn health(State(ctx): State<Arc<ServerCtx>>) -> Json<crate::health::HealthResponse> {
let lan_ip = Some(*ctx.lan_ip.lock().unwrap());
Json(crate::health::HealthResponse::build(
&ctx.health_meta,
lan_ip,
))
} }
// --- Blocking handlers --- // --- Blocking handlers ---
@@ -916,8 +865,12 @@ async fn remove_route(
} }
} }
pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> { async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResponse, StatusCode> {
let pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?; let ca_path = ctx.data_dir.join("ca.pem");
let bytes = tokio::task::spawn_blocking(move || std::fs::read(ca_path))
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.map_err(|_| StatusCode::NOT_FOUND)?;
Ok(( Ok((
[ [
(header::CONTENT_TYPE, "application/x-pem-file"), (header::CONTENT_TYPE, "application/x-pem-file"),
@@ -927,7 +880,7 @@ pub async fn serve_ca(State(ctx): State<Arc<ServerCtx>>) -> Result<impl IntoResp
), ),
(header::CACHE_CONTROL, "public, max-age=86400"), (header::CACHE_CONTROL, "public, max-age=86400"),
], ],
pem.to_string(), bytes,
)) ))
} }
@@ -1000,11 +953,8 @@ mod tests {
upstream_mode: crate::config::UpstreamMode::Forward, upstream_mode: crate::config::UpstreamMode::Forward,
root_hints: Vec::new(), root_hints: Vec::new(),
srtt: RwLock::new(crate::srtt::SrttCache::new(true)), srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
inflight: Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: false, dnssec_enabled: false,
dnssec_strict: false, dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
}) })
} }

View File

@@ -81,70 +81,66 @@ impl BlocklistStore {
if !self.enabled { if !self.enabled {
return false; return false;
} }
if let Some(until) = self.paused_until { if let Some(until) = self.paused_until {
if Instant::now() < until { if Instant::now() < until {
return false; return false;
} }
} }
let domain = Self::normalize(domain);
if Self::find_in_set(&domain, &self.allowlist).is_some() { if self.allowlist.contains(domain) {
return false; return false;
} }
Self::find_in_set(&domain, &self.domains).is_some()
if self.domains.contains(domain) {
return true;
}
// Walk up: ads.tracker.example.com → tracker.example.com → example.com
let mut d = domain;
while let Some(dot) = d.find('.') {
d = &d[dot + 1..];
if self.allowlist.contains(d) {
return false;
}
if self.domains.contains(d) {
return true;
}
}
false
} }
/// Check if a domain is blocked and return the reason.
pub fn check(&self, domain: &str) -> BlockCheckResult { pub fn check(&self, domain: &str) -> BlockCheckResult {
let domain = domain.to_lowercase();
if !self.enabled { if !self.enabled {
return BlockCheckResult::disabled(); return BlockCheckResult::disabled();
} }
if let Some(until) = self.paused_until { if self.allowlist.contains(&domain) {
if Instant::now() < until { return BlockCheckResult::allowed(&domain, "exact match in allowlist");
return BlockCheckResult::disabled(); }
if self.domains.contains(&domain) {
return BlockCheckResult::blocked(&domain, "exact match in blocklist");
}
let mut d = domain.as_str();
while let Some(dot) = d.find('.') {
d = &d[dot + 1..];
if self.allowlist.contains(d) {
return BlockCheckResult::allowed(d, "parent domain in allowlist");
}
if self.domains.contains(d) {
return BlockCheckResult::blocked(d, "parent domain in blocklist");
} }
}
let domain = Self::normalize(domain);
if let Some(matched) = Self::find_in_set(&domain, &self.allowlist) {
let reason = if matched == domain {
"exact match in allowlist"
} else {
"parent domain in allowlist"
};
return BlockCheckResult::allowed(matched, reason);
}
if let Some(matched) = Self::find_in_set(&domain, &self.domains) {
let reason = if matched == domain {
"exact match in blocklist"
} else {
"parent domain in blocklist"
};
return BlockCheckResult::blocked(matched, reason);
} }
BlockCheckResult::not_blocked() BlockCheckResult::not_blocked()
} }
fn normalize(domain: &str) -> String {
domain.to_lowercase().trim_end_matches('.').to_string()
}
fn find_in_set<'a>(domain: &'a str, set: &HashSet<String>) -> Option<&'a str> {
if set.contains(domain) {
return Some(domain);
}
let mut d = domain;
while let Some(dot) = d.find('.') {
d = &d[dot + 1..];
if set.contains(d) {
return Some(d);
}
}
None
}
/// Atomically swap in a new domain set. Build the set outside the lock, /// Atomically swap in a new domain set. Build the set outside the lock,
/// then call this to swap — keeps lock hold time sub-microsecond. /// then call this to swap — keeps lock hold time sub-microsecond.
pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) { pub fn swap_domains(&mut self, domains: HashSet<String>, sources: Vec<String>) {
@@ -176,26 +172,17 @@ impl BlocklistStore {
} }
pub fn add_to_allowlist(&mut self, domain: &str) { pub fn add_to_allowlist(&mut self, domain: &str) {
self.allowlist.insert(Self::normalize(domain)); self.allowlist.insert(domain.to_lowercase());
} }
pub fn remove_from_allowlist(&mut self, domain: &str) -> bool { pub fn remove_from_allowlist(&mut self, domain: &str) -> bool {
self.allowlist.remove(&Self::normalize(domain)) self.allowlist.remove(&domain.to_lowercase())
} }
pub fn allowlist(&self) -> Vec<String> { pub fn allowlist(&self) -> Vec<String> {
self.allowlist.iter().cloned().collect() self.allowlist.iter().cloned().collect()
} }
pub fn heap_bytes(&self) -> usize {
let per_slot_overhead = std::mem::size_of::<u64>() + std::mem::size_of::<String>() + 1;
let domains_table = self.domains.capacity() * per_slot_overhead;
let domains_heap: usize = self.domains.iter().map(|d| d.capacity()).sum();
let allow_table = self.allowlist.capacity() * per_slot_overhead;
let allow_heap: usize = self.allowlist.iter().map(|d| d.capacity()).sum();
domains_table + domains_heap + allow_table + allow_heap
}
pub fn stats(&self) -> BlocklistStats { pub fn stats(&self) -> BlocklistStats {
BlocklistStats { BlocklistStats {
enabled: self.is_enabled(), enabled: self.is_enabled(),
@@ -247,114 +234,6 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
domains domains
} }
#[cfg(test)]
mod tests {
use super::*;
fn store_with(domains: &[&str], allowlist: &[&str]) -> BlocklistStore {
let mut store = BlocklistStore::new();
store.swap_domains(domains.iter().map(|s| s.to_string()).collect(), vec![]);
for d in allowlist {
store.add_to_allowlist(d);
}
store
}
#[test]
fn exact_block() {
let store = store_with(&["ads.example.com"], &[]);
assert!(store.is_blocked("ads.example.com"));
assert!(!store.is_blocked("example.com"));
}
#[test]
fn parent_block_covers_subdomain() {
let store = store_with(&["tracker.com"], &[]);
assert!(store.is_blocked("tracker.com"));
assert!(store.is_blocked("www.tracker.com"));
assert!(store.is_blocked("deep.sub.tracker.com"));
}
#[test]
fn exact_allowlist_unblocks() {
let store = store_with(&["ads.example.com"], &["ads.example.com"]);
assert!(!store.is_blocked("ads.example.com"));
}
#[test]
fn parent_allowlist_unblocks_subdomain() {
let store = store_with(&["example.com", "www.example.com"], &["example.com"]);
assert!(!store.is_blocked("example.com"));
assert!(!store.is_blocked("www.example.com"));
assert!(!store.is_blocked("sub.deep.example.com"));
}
#[test]
fn allowlist_does_not_unblock_sibling() {
let store = store_with(
&["www.example.com", "ads.example.com"],
&["www.example.com"],
);
assert!(!store.is_blocked("www.example.com"));
assert!(store.is_blocked("ads.example.com"));
}
#[test]
fn check_reports_parent_allowlist() {
let store = store_with(
&["goatcounter.com", "www.goatcounter.com"],
&["goatcounter.com"],
);
let result = store.check("www.goatcounter.com");
assert!(!result.blocked);
assert_eq!(result.matched_rule.as_deref(), Some("goatcounter.com"));
}
#[test]
fn disabled_never_blocks() {
let mut store = store_with(&["ads.example.com"], &[]);
store.set_enabled(false);
assert!(!store.is_blocked("ads.example.com"));
}
#[test]
fn trailing_dot_normalized() {
let store = store_with(&["ads.example.com"], &["safe.example.com"]);
assert!(store.is_blocked("ads.example.com."));
assert!(!store.is_blocked("safe.example.com."));
let result = store.check("ads.example.com.");
assert!(result.blocked);
}
#[test]
fn case_insensitive() {
let store = store_with(&["ads.example.com"], &["safe.example.com"]);
assert!(store.is_blocked("ADS.Example.COM"));
assert!(!store.is_blocked("Safe.Example.COM"));
}
#[test]
fn domain_in_neither_list() {
let store = store_with(&["ads.example.com"], &[]);
let result = store.check("clean.example.org");
assert!(!result.blocked);
assert_eq!(result.reason, "not in blocklist");
assert!(result.matched_rule.is_none());
}
#[test]
fn heap_bytes_grows_with_domains() {
let mut store = BlocklistStore::new();
let empty = store.heap_bytes();
let domains: HashSet<String> = ["example.com", "example.org", "test.net"]
.iter()
.map(|s| s.to_string())
.collect();
store.swap_domains(domains, vec![]);
assert!(store.heap_bytes() > empty);
}
}
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> { pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))

View File

@@ -84,11 +84,6 @@ impl BytePacketBuffer {
/// Read a qname, handling label compression (pointer jumps). /// Read a qname, handling label compression (pointer jumps).
/// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com". /// Converts wire format like [3]www[6]google[3]com[0] into "www.google.com".
///
/// Label bytes are escaped per RFC 1035 §5.1:
/// - literal `.` within a label → `\.`
/// - literal `\` → `\\`
/// - bytes outside `0x21..=0x7E` (excluding `.` and `\`) → `\DDD` (3-digit decimal)
pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> { pub fn read_qname(&mut self, outstr: &mut String) -> Result<()> {
let mut pos = self.pos(); let mut pos = self.pos();
let mut jumped = false; let mut jumped = false;
@@ -126,18 +121,7 @@ impl BytePacketBuffer {
let str_buffer = self.get_range(pos, len as usize)?; let str_buffer = self.get_range(pos, len as usize)?;
for &b in str_buffer { for &b in str_buffer {
let c = b.to_ascii_lowercase(); outstr.push(b.to_ascii_lowercase() as char);
match c {
b'.' => outstr.push_str("\\."),
b'\\' => outstr.push_str("\\\\"),
0x21..=0x7E => outstr.push(c as char),
_ => {
outstr.push('\\');
outstr.push((b'0' + c / 100) as char);
outstr.push((b'0' + (c / 10) % 10) as char);
outstr.push((b'0' + c % 10) as char);
}
}
} }
delim = "."; delim = ".";
@@ -179,68 +163,24 @@ impl BytePacketBuffer {
Ok(()) Ok(())
} }
/// Write a qname in wire format, parsing RFC 1035 §5.1 text escapes.
/// See `read_qname` for the escape grammar.
pub fn write_qname(&mut self, qname: &str) -> Result<()> { pub fn write_qname(&mut self, qname: &str) -> Result<()> {
if qname.is_empty() || qname == "." { if qname.is_empty() || qname == "." {
self.write_u8(0)?; self.write_u8(0)?;
return Ok(()); return Ok(());
} }
let bytes = qname.as_bytes(); for label in qname.split('.') {
let mut i = 0; let len = label.len();
while i < bytes.len() { if len == 0 {
let len_pos = self.pos; continue; // skip empty labels from trailing dot
self.write_u8(0)?; // placeholder length byte, backpatched below }
let body_start = self.pos; if len > 0x3f {
return Err("Single label exceeds 63 characters of length".into());
while i < bytes.len() && bytes[i] != b'.' {
let b = bytes[i];
if b == b'\\' {
i += 1;
let c1 = *bytes.get(i).ok_or("trailing backslash in qname")?;
if c1.is_ascii_digit() {
let c2 = *bytes
.get(i + 1)
.ok_or("invalid \\DDD escape: expected 3 digits")?;
let c3 = *bytes
.get(i + 2)
.ok_or("invalid \\DDD escape: expected 3 digits")?;
if !c2.is_ascii_digit() || !c3.is_ascii_digit() {
return Err("invalid \\DDD escape: expected 3 digits".into());
}
let val =
(c1 - b'0') as u16 * 100 + (c2 - b'0') as u16 * 10 + (c3 - b'0') as u16;
if val > 255 {
return Err(format!("\\DDD escape out of range: {}", val).into());
}
self.write_u8(val as u8)?;
i += 3;
} else {
// \. \\ and any other \X → literal next byte
self.write_u8(c1)?;
i += 1;
}
} else {
self.write_u8(b)?;
i += 1;
}
if self.pos - body_start > 0x3f {
return Err("Single label exceeds 63 characters of length".into());
}
} }
let label_len = self.pos - body_start; self.write_u8(len as u8)?;
if label_len == 0 && i < bytes.len() { for b in label.as_bytes() {
// Empty label from leading/consecutive dots — roll back the placeholder. self.write_u8(*b)?;
self.pos = len_pos;
} else {
self.set(len_pos, label_len as u8)?;
}
if i < bytes.len() && bytes[i] == b'.' {
i += 1;
} }
} }
@@ -272,160 +212,3 @@ impl BytePacketBuffer {
Ok(()) Ok(())
} }
} }
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(wire: &[u8]) -> String {
let mut buf = BytePacketBuffer::from_bytes(wire);
let mut out = String::new();
buf.read_qname(&mut out).unwrap();
out
}
fn write_then_read(text: &str) -> String {
let mut buf = BytePacketBuffer::new();
buf.write_qname(text).unwrap();
let wire_end = buf.pos();
buf.seek(0).unwrap();
let mut out = String::new();
buf.read_qname(&mut out).unwrap();
assert_eq!(
buf.pos(),
wire_end,
"reader should consume exactly what writer wrote"
);
out
}
#[test]
fn read_plain_domain() {
// [3]www[6]google[3]com[0]
let wire = b"\x03www\x06google\x03com\x00";
assert_eq!(roundtrip(wire), "www.google.com");
}
#[test]
fn read_label_with_literal_dot_is_escaped() {
// fanf2's example: [8]exa.mple[3]com[0] — two labels, first contains 0x2E
let wire = b"\x08exa.mple\x03com\x00";
assert_eq!(roundtrip(wire), "exa\\.mple.com");
}
#[test]
fn read_label_with_backslash_is_escaped() {
// [4]a\bc[3]com[0]
let wire = b"\x04a\\bc\x03com\x00";
assert_eq!(roundtrip(wire), "a\\\\bc.com");
}
#[test]
fn read_label_with_nonprintable_byte_uses_decimal_escape() {
// [4]\x00foo[3]com[0] — null byte at label start
let wire = b"\x04\x00foo\x03com\x00";
assert_eq!(roundtrip(wire), "\\000foo.com");
}
#[test]
fn read_label_with_space_uses_decimal_escape() {
// Space (0x20) is outside 0x21..=0x7E, so it must be decimal-escaped.
let wire = b"\x05a b c\x00";
assert_eq!(roundtrip(wire), "a\\032b\\032c");
}
#[test]
fn write_plain_domain() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("www.google.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x03www\x06google\x03com\x00");
}
#[test]
fn write_escaped_dot_does_not_split_label() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("exa\\.mple.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x08exa.mple\x03com\x00");
}
#[test]
fn write_escaped_backslash() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("a\\\\bc.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x04a\\bc\x03com\x00");
}
#[test]
fn write_decimal_escape_yields_raw_byte() {
let mut buf = BytePacketBuffer::new();
buf.write_qname("\\000foo.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x04\x00foo\x03com\x00");
}
#[test]
fn write_skips_empty_labels() {
// Leading dot — first (empty) label is rolled back.
let mut buf = BytePacketBuffer::new();
buf.write_qname(".foo.com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
// Consecutive dots — middle empty label is rolled back.
let mut buf = BytePacketBuffer::new();
buf.write_qname("foo..com").unwrap();
assert_eq!(&buf.buf[..buf.pos], b"\x03foo\x03com\x00");
}
#[test]
fn write_rejects_out_of_range_decimal_escape() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("\\999foo.com").is_err());
}
#[test]
fn write_rejects_trailing_backslash() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("foo\\").is_err());
}
#[test]
fn write_rejects_short_decimal_escape() {
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname("\\1").is_err());
}
#[test]
fn write_rejects_label_over_63_bytes() {
// 64 bytes exceeds the wire-format label cap.
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname(&"a".repeat(64)).is_err());
// 63 bytes is the maximum permitted label length.
let mut buf = BytePacketBuffer::new();
assert!(buf.write_qname(&"a".repeat(63)).is_ok());
}
#[test]
fn roundtrip_preserves_dot_in_label() {
assert_eq!(write_then_read("exa\\.mple.com"), "exa\\.mple.com");
}
#[test]
fn roundtrip_preserves_backslash_in_label() {
assert_eq!(write_then_read("a\\\\b.com"), "a\\\\b.com");
}
#[test]
fn roundtrip_preserves_nonprintable_byte() {
assert_eq!(write_then_read("\\000foo.com"), "\\000foo.com");
}
#[test]
fn root_name_empty_and_dot_both_produce_single_zero() {
let mut a = BytePacketBuffer::new();
a.write_qname("").unwrap();
let mut b = BytePacketBuffer::new();
b.write_qname(".").unwrap();
assert_eq!(&a.buf[..a.pos], b"\x00");
assert_eq!(&b.buf[..b.pos], b"\x00");
}
}

View File

@@ -142,26 +142,6 @@ impl DnsCache {
self.entry_count = 0; self.entry_count = 0;
} }
pub fn heap_bytes(&self) -> usize {
let outer_slot = std::mem::size_of::<u64>()
+ std::mem::size_of::<String>()
+ std::mem::size_of::<HashMap<QueryType, CacheEntry>>()
+ 1;
let mut total = self.entries.capacity() * outer_slot;
for (domain, type_map) in &self.entries {
total += domain.capacity();
let inner_slot = std::mem::size_of::<u64>()
+ std::mem::size_of::<QueryType>()
+ std::mem::size_of::<CacheEntry>()
+ 1;
total += type_map.capacity() * inner_slot;
for entry in type_map.values() {
total += entry.packet.heap_bytes();
}
}
total
}
pub fn remove(&mut self, domain: &str) { pub fn remove(&mut self, domain: &str) {
let domain_lower = domain.to_lowercase(); let domain_lower = domain.to_lowercase();
if let Some(type_map) = self.entries.remove(&domain_lower) { if let Some(type_map) = self.entries.remove(&domain_lower) {
@@ -214,23 +194,3 @@ fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) {
record.set_ttl(new_ttl); record.set_ttl(new_ttl);
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::packet::DnsPacket;
#[test]
fn heap_bytes_grows_with_entries() {
let mut cache = DnsCache::new(100, 1, 3600);
let empty = cache.heap_bytes();
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);
assert!(cache.heap_bytes() > empty);
}
}

View File

@@ -1,7 +1,7 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::net::Ipv6Addr; use std::net::Ipv6Addr;
use std::path::{Path, PathBuf}; use std::path::Path;
use serde::Deserialize; use serde::Deserialize;
@@ -29,10 +29,6 @@ pub struct Config {
pub lan: LanConfig, pub lan: LanConfig,
#[serde(default)] #[serde(default)]
pub dnssec: DnssecConfig, pub dnssec: DnssecConfig,
#[serde(default)]
pub dot: DotConfig,
#[serde(default)]
pub mobile: MobileConfig,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -43,10 +39,6 @@ pub struct ServerConfig {
pub api_port: u16, pub api_port: u16,
#[serde(default = "default_api_bind_addr")] #[serde(default = "default_api_bind_addr")]
pub api_bind_addr: String, pub api_bind_addr: String,
/// Where numa writes TLS material (CA, leaf certs, regenerated state).
/// Defaults to `crate::data_dir()` (platform-specific system path) if unset.
#[serde(default)]
pub data_dir: Option<PathBuf>,
} }
impl Default for ServerConfig { impl Default for ServerConfig {
@@ -55,7 +47,6 @@ impl Default for ServerConfig {
bind_addr: default_bind_addr(), bind_addr: default_bind_addr(),
api_port: default_api_port(), api_port: default_api_port(),
api_bind_addr: default_api_bind_addr(), api_bind_addr: default_api_bind_addr(),
data_dir: None,
} }
} }
} }
@@ -68,31 +59,18 @@ fn default_bind_addr() -> String {
"0.0.0.0:53".to_string() "0.0.0.0:53".to_string()
} }
pub const DEFAULT_API_PORT: u16 = 5380;
fn default_api_port() -> u16 { fn default_api_port() -> u16 {
DEFAULT_API_PORT 5380
} }
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)] #[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
pub enum UpstreamMode { pub enum UpstreamMode {
Auto,
#[default] #[default]
Forward, Forward,
Recursive, Recursive,
} }
impl UpstreamMode {
pub fn as_str(&self) -> &'static str {
match self {
UpstreamMode::Auto => "auto",
UpstreamMode::Forward => "forward",
UpstreamMode::Recursive => "recursive",
}
}
}
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct UpstreamConfig { pub struct UpstreamConfig {
#[serde(default)] #[serde(default)]
@@ -125,12 +103,8 @@ impl Default for UpstreamConfig {
} }
} }
fn default_true() -> bool {
true
}
fn default_srtt() -> bool { fn default_srtt() -> bool {
default_true() true
} }
fn default_prime_tlds() -> Vec<String> { fn default_prime_tlds() -> Vec<String> {
@@ -379,88 +353,6 @@ pub struct DnssecConfig {
pub strict: bool, pub strict: bool,
} }
#[derive(Deserialize, Clone)]
pub struct DotConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_dot_port")]
pub port: u16,
#[serde(default = "default_dot_bind_addr")]
pub bind_addr: String,
/// Path to TLS certificate (PEM). If None, uses self-signed CA.
#[serde(default)]
pub cert_path: Option<PathBuf>,
/// Path to TLS private key (PEM). If None, uses self-signed CA.
#[serde(default)]
pub key_path: Option<PathBuf>,
}
impl Default for DotConfig {
fn default() -> Self {
DotConfig {
enabled: false,
port: default_dot_port(),
bind_addr: default_dot_bind_addr(),
cert_path: None,
key_path: None,
}
}
}
fn default_dot_port() -> u16 {
853
}
fn default_dot_bind_addr() -> String {
"0.0.0.0".to_string()
}
/// Configuration for the mobile API — a persistent HTTP listener that
/// serves a read-only subset of routes (`/health`, `/ca.pem`,
/// `/mobileconfig`, `/ca.mobileconfig`) on a LAN-reachable port, for
/// consumption by the iOS/Android companion apps.
///
/// Unlike the main API (port 5380, localhost-only by default, supports
/// state-mutating routes), the mobile API is safe to expose on the LAN
/// because every route is idempotent and read-only.
#[derive(Deserialize, Clone)]
pub struct MobileConfig {
/// If true, spawn the mobile API listener at startup. **Default false.**
/// Opt-in because the listener binds to the LAN by default and exposes
/// a few read-only endpoints to any device on the same network (`/health`,
/// `/ca.pem`, `/mobileconfig`, `/ca.mobileconfig`). None of those are
/// cryptographically sensitive (the CA private key is never served),
/// but users should enable this explicitly rather than have a new
/// LAN-reachable port appear after an upgrade.
#[serde(default)]
pub enabled: bool,
/// Port for the mobile API. Default 8765.
#[serde(default = "default_mobile_port")]
pub port: u16,
/// Bind address for the mobile API. Default "0.0.0.0" (all interfaces)
/// so phones on the LAN can reach it. Set to "127.0.0.1" to restrict
/// to localhost — useful if you're running behind another front-end.
#[serde(default = "default_mobile_bind_addr")]
pub bind_addr: String,
}
impl Default for MobileConfig {
fn default() -> Self {
MobileConfig {
enabled: false,
port: default_mobile_port(),
bind_addr: default_mobile_bind_addr(),
}
}
}
fn default_mobile_port() -> u16 {
8765
}
fn default_mobile_bind_addr() -> String {
"0.0.0.0".to_string()
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Mutex, RwLock}; use std::sync::{Mutex, RwLock};
@@ -8,9 +7,6 @@ use arc_swap::ArcSwap;
use log::{debug, error, info, warn}; use log::{debug, error, info, warn};
use rustls::ServerConfig; use rustls::ServerConfig;
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
use tokio::sync::broadcast;
type InflightMap = HashMap<(String, QueryType), broadcast::Sender<Option<DnsPacket>>>;
use crate::blocklist::BlocklistStore; use crate::blocklist::BlocklistStore;
use crate::buffer::BytePacketBuffer; use crate::buffer::BytePacketBuffer;
@@ -18,7 +14,6 @@ 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, Upstream};
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::health::HealthMeta;
use crate::lan::PeerStore; use crate::lan::PeerStore;
use crate::override_store::OverrideStore; use crate::override_store::OverrideStore;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
@@ -58,35 +53,28 @@ pub struct ServerCtx {
pub upstream_mode: UpstreamMode, pub upstream_mode: UpstreamMode,
pub root_hints: Vec<SocketAddr>, pub root_hints: Vec<SocketAddr>,
pub srtt: RwLock<SrttCache>, pub srtt: RwLock<SrttCache>,
pub inflight: Mutex<InflightMap>,
pub dnssec_enabled: bool, pub dnssec_enabled: bool,
pub dnssec_strict: bool, pub dnssec_strict: bool,
/// Cached health metadata (version, hostname, DoT config, CA
/// fingerprint, features). Shared between the main and mobile
/// API `/health` handlers. Built once at startup in `main.rs`.
pub health_meta: HealthMeta,
/// CA certificate in PEM form, cached at startup. `None` if no
/// TLS-using feature is enabled and the CA hasn't been generated.
/// Used by `/ca.pem`, `/mobileconfig`, and `/ca.mobileconfig`
/// handlers to avoid per-request disk I/O on the hot path.
pub ca_pem: Option<String>,
} }
/// Transport-agnostic DNS resolution. Runs the full pipeline (overrides, blocklist, pub async fn handle_query(
/// cache, upstream, DNSSEC) and returns the serialized response in a buffer. mut buffer: BytePacketBuffer,
/// Callers use `.filled()` to get the response bytes without heap allocation.
/// Callers are responsible for parsing the incoming buffer into a `DnsPacket`
/// (and logging parse errors) before calling this function.
pub async fn resolve_query(
query: DnsPacket,
src_addr: SocketAddr, src_addr: SocketAddr,
ctx: &ServerCtx, ctx: &ServerCtx,
) -> crate::Result<BytePacketBuffer> { ) -> crate::Result<()> {
let start = Instant::now(); let start = Instant::now();
let query = match DnsPacket::from_buffer(&mut buffer) {
Ok(packet) => packet,
Err(e) => {
warn!("{} | PARSE ERROR | {}", src_addr, e);
return Ok(());
}
};
let (qname, qtype) = match query.questions.first() { let (qname, qtype) = match query.questions.first() {
Some(q) => (q.name.clone(), q.qtype), Some(q) => (q.name.clone(), q.qtype),
None => return Err("empty question section".into()), None => return Ok(()),
}; };
// Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream
@@ -100,13 +88,18 @@ pub async fn resolve_query(
} else if qname == "localhost" || qname.ends_with(".localhost") { } else if qname == "localhost" || qname.ends_with(".localhost") {
// RFC 6761: .localhost always resolves to loopback // RFC 6761: .localhost always resolves to loopback
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers.push(sinkhole_record( match qtype {
&qname, QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
qtype, domain: qname.clone(),
std::net::Ipv4Addr::LOCALHOST, addr: std::net::Ipv6Addr::LOCALHOST,
std::net::Ipv6Addr::LOCALHOST, ttl: 300,
300, }),
)); _ => resp.answers.push(DnsRecord::A {
domain: qname.clone(),
addr: std::net::Ipv4Addr::LOCALHOST,
ttl: 300,
}),
}
(resp, QueryPath::Local, DnssecStatus::Indeterminate) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if is_special_use_domain(&qname) { } else if is_special_use_domain(&qname) {
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
@@ -115,17 +108,12 @@ pub async fn resolve_query(
} else if !ctx.proxy_tld_suffix.is_empty() } else if !ctx.proxy_tld_suffix.is_empty()
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
{ {
// Resolve .numa: remote clients get LAN IP (can't reach 127.0.0.1), local get loopback // Resolve .numa: local services → 127.0.0.1, LAN peers → peer IP
let service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname); let service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname);
let is_remote = !src_addr.ip().is_loopback();
let resolve_ip = { let resolve_ip = {
let local = ctx.services.lock().unwrap(); let local = ctx.services.lock().unwrap();
if local.lookup(service_name).is_some() { if local.lookup(service_name).is_some() {
if is_remote { std::net::Ipv4Addr::LOCALHOST
*ctx.lan_ip.lock().unwrap()
} else {
std::net::Ipv4Addr::LOCALHOST
}
} else { } else {
let mut peers = ctx.lan_peers.lock().unwrap(); let mut peers = ctx.lan_peers.lock().unwrap();
peers peers
@@ -137,24 +125,38 @@ pub async fn resolve_query(
.unwrap_or(std::net::Ipv4Addr::LOCALHOST) .unwrap_or(std::net::Ipv4Addr::LOCALHOST)
} }
}; };
let v6 = if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
std::net::Ipv6Addr::LOCALHOST
} else {
resolve_ip.to_ipv6_mapped()
};
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers match qtype {
.push(sinkhole_record(&qname, qtype, resolve_ip, v6, 300)); QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
domain: qname.clone(),
addr: if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
std::net::Ipv6Addr::LOCALHOST
} else {
resolve_ip.to_ipv6_mapped()
},
ttl: 300,
}),
_ => resp.answers.push(DnsRecord::A {
domain: qname.clone(),
addr: resolve_ip,
ttl: 300,
}),
}
(resp, QueryPath::Local, DnssecStatus::Indeterminate) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) { } else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.answers.push(sinkhole_record( match qtype {
&qname, QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
qtype, domain: qname.clone(),
std::net::Ipv4Addr::UNSPECIFIED, addr: std::net::Ipv6Addr::UNSPECIFIED,
std::net::Ipv6Addr::UNSPECIFIED, ttl: 60,
60, }),
)); _ => resp.answers.push(DnsRecord::A {
domain: qname.clone(),
addr: std::net::Ipv4Addr::UNSPECIFIED,
ttl: 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)) { } 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); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
@@ -169,20 +171,21 @@ pub async fn resolve_query(
resp.header.authed_data = true; resp.header.authed_data = true;
} }
(resp, QueryPath::Cached, cached_dnssec) (resp, QueryPath::Cached, cached_dnssec)
} else if let Some(fwd_addr) = } else if ctx.upstream_mode == UpstreamMode::Recursive {
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) match crate::recursive::resolve_recursive(
{ &qname,
// Conditional forwarding takes priority over recursive mode qtype,
// (e.g. Tailscale .ts.net, VPC private zones) &ctx.cache,
let upstream = Upstream::Udp(fwd_addr); &query,
match forward_query(&query, &upstream, ctx.timeout).await { &ctx.root_hints,
Ok(resp) => { &ctx.srtt,
ctx.cache.write().unwrap().insert(&qname, qtype, &resp); )
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate) .await
} {
Ok(resp) => (resp, QueryPath::Recursive, DnssecStatus::Indeterminate),
Err(e) => { Err(e) => {
error!( error!(
"{} | {:?} {} | FORWARD ERROR | {}", "{} | {:?} {} | RECURSIVE ERROR | {}",
src_addr, qtype, qname, e src_addr, qtype, qname, e
); );
( (
@@ -192,31 +195,6 @@ pub async fn resolve_query(
) )
} }
} }
} else if ctx.upstream_mode == UpstreamMode::Recursive {
let key = (qname.clone(), qtype);
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
crate::recursive::resolve_recursive(
&qname,
qtype,
&ctx.cache,
&query,
&ctx.root_hints,
&ctx.srtt,
)
})
.await;
if path == QueryPath::Coalesced {
debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname);
} else if path == QueryPath::UpstreamError {
error!(
"{} | {:?} {} | RECURSIVE ERROR | {}",
src_addr,
qtype,
qname,
err.as_deref().unwrap_or("leader failed")
);
}
(resp, path, DnssecStatus::Indeterminate)
} else { } else {
let upstream = let upstream =
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
@@ -313,17 +291,17 @@ pub async fn resolve_query(
response.resources.len(), response.resources.len(),
); );
// Serialize response
// TODO: TC bit is UDP-specific; DoT connections could carry up to 65535 bytes.
// Once BytePacketBuffer supports larger buffers, skip truncation for TCP/TLS.
let mut resp_buffer = BytePacketBuffer::new(); let mut resp_buffer = BytePacketBuffer::new();
if response.write(&mut resp_buffer).is_err() { if response.write(&mut resp_buffer).is_err() {
// Response too large — set TC bit and send header + question only // Response too large for UDP — set TC bit and send header + question only
debug!("response too large, setting TC bit for {}", qname); debug!("response too large, setting TC bit for {}", qname);
let mut tc_response = DnsPacket::response_from(&query, response.header.rescode); let mut tc_response = DnsPacket::response_from(&query, response.header.rescode);
tc_response.header.truncated_message = true; tc_response.header.truncated_message = true;
resp_buffer = BytePacketBuffer::new(); let mut tc_buffer = BytePacketBuffer::new();
tc_response.write(&mut resp_buffer)?; tc_response.write(&mut tc_buffer)?;
ctx.socket.send_to(tc_buffer.filled(), src_addr).await?;
} else {
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
} }
// Record stats and query log // Record stats and query log
@@ -346,30 +324,6 @@ pub async fn resolve_query(
dnssec, dnssec,
}); });
Ok(resp_buffer)
}
/// Handle a DNS query received over UDP. Thin wrapper around resolve_query.
pub async fn handle_query(
mut buffer: BytePacketBuffer,
src_addr: SocketAddr,
ctx: &ServerCtx,
) -> crate::Result<()> {
let query = match DnsPacket::from_buffer(&mut buffer) {
Ok(packet) => packet,
Err(e) => {
warn!("{} | PARSE ERROR | {}", src_addr, e);
return Ok(());
}
};
match resolve_query(query, src_addr, ctx).await {
Ok(resp_buffer) => {
ctx.socket.send_to(resp_buffer.filled(), src_addr).await?;
}
Err(e) => {
warn!("{} | RESOLVE ERROR | {}", src_addr, e);
}
}
Ok(()) Ok(())
} }
@@ -423,105 +377,6 @@ fn is_special_use_domain(qname: &str) -> bool {
qname == "local" || qname.ends_with(".local") qname == "local" || qname.ends_with(".local")
} }
fn sinkhole_record(
domain: &str,
qtype: QueryType,
v4: std::net::Ipv4Addr,
v6: std::net::Ipv6Addr,
ttl: u32,
) -> DnsRecord {
match qtype {
QueryType::AAAA => DnsRecord::AAAA {
domain: domain.to_string(),
addr: v6,
ttl,
},
_ => DnsRecord::A {
domain: domain.to_string(),
addr: v4,
ttl,
},
}
}
enum Disposition {
Leader(broadcast::Sender<Option<DnsPacket>>),
Follower(broadcast::Receiver<Option<DnsPacket>>),
}
fn acquire_inflight(inflight: &Mutex<InflightMap>, key: (String, QueryType)) -> Disposition {
let mut map = inflight.lock().unwrap();
if let Some(tx) = map.get(&key) {
Disposition::Follower(tx.subscribe())
} else {
let (tx, _) = broadcast::channel::<Option<DnsPacket>>(1);
map.insert(key, tx.clone());
Disposition::Leader(tx)
}
}
/// Run a resolve function with in-flight coalescing. Multiple concurrent calls
/// for the same key share a single resolution — the first caller (leader)
/// executes `resolve_fn`, and followers wait for the broadcast result.
async fn resolve_coalesced<F, Fut>(
inflight: &Mutex<InflightMap>,
key: (String, QueryType),
query: &DnsPacket,
resolve_fn: F,
) -> (DnsPacket, QueryPath, Option<String>)
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = crate::Result<DnsPacket>>,
{
let disposition = acquire_inflight(inflight, key.clone());
match disposition {
Disposition::Follower(mut rx) => match rx.recv().await {
Ok(Some(mut resp)) => {
resp.header.id = query.header.id;
(resp, QueryPath::Coalesced, None)
}
_ => (
DnsPacket::response_from(query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
None,
),
},
Disposition::Leader(tx) => {
let guard = InflightGuard { inflight, key };
let result = resolve_fn().await;
drop(guard);
match result {
Ok(resp) => {
let _ = tx.send(Some(resp.clone()));
(resp, QueryPath::Recursive, None)
}
Err(e) => {
let _ = tx.send(None);
let err_msg = e.to_string();
(
DnsPacket::response_from(query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
Some(err_msg),
)
}
}
}
}
}
struct InflightGuard<'a> {
inflight: &'a Mutex<InflightMap>,
key: (String, QueryType),
}
impl Drop for InflightGuard<'_> {
fn drop(&mut self) {
self.inflight.lock().unwrap().remove(&self.key);
}
}
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket { fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
use std::net::{Ipv4Addr, Ipv6Addr}; use std::net::{Ipv4Addr, Ipv6Addr};
if qname == "ipv4only.arpa" { if qname == "ipv4only.arpa" {
@@ -555,391 +410,3 @@ fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> Dns
DnsPacket::response_from(query, ResultCode::NXDOMAIN) DnsPacket::response_from(query, ResultCode::NXDOMAIN)
} }
} }
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
// ---- InflightGuard unit tests ----
#[test]
fn inflight_guard_removes_key_on_drop() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key = ("example.com".to_string(), QueryType::A);
let (tx, _) = broadcast::channel::<Option<DnsPacket>>(1);
map.lock().unwrap().insert(key.clone(), tx);
assert_eq!(map.lock().unwrap().len(), 1);
{
let _guard = InflightGuard {
inflight: &map,
key: key.clone(),
};
} // guard dropped here
assert!(map.lock().unwrap().is_empty());
}
#[test]
fn inflight_guard_only_removes_own_key() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key_a = ("a.com".to_string(), QueryType::A);
let key_b = ("b.com".to_string(), QueryType::A);
let (tx_a, _) = broadcast::channel::<Option<DnsPacket>>(1);
let (tx_b, _) = broadcast::channel::<Option<DnsPacket>>(1);
map.lock().unwrap().insert(key_a.clone(), tx_a);
map.lock().unwrap().insert(key_b.clone(), tx_b);
{
let _guard = InflightGuard {
inflight: &map,
key: key_a,
};
}
let m = map.lock().unwrap();
assert_eq!(m.len(), 1);
assert!(m.contains_key(&key_b));
}
#[test]
fn inflight_guard_same_domain_different_qtype_independent() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key_a = ("example.com".to_string(), QueryType::A);
let key_aaaa = ("example.com".to_string(), QueryType::AAAA);
let (tx_a, _) = broadcast::channel::<Option<DnsPacket>>(1);
let (tx_aaaa, _) = broadcast::channel::<Option<DnsPacket>>(1);
map.lock().unwrap().insert(key_a.clone(), tx_a);
map.lock().unwrap().insert(key_aaaa.clone(), tx_aaaa);
{
let _guard = InflightGuard {
inflight: &map,
key: key_a,
};
}
let m = map.lock().unwrap();
assert_eq!(m.len(), 1);
assert!(m.contains_key(&key_aaaa));
}
// ---- Coalescing disposition tests (via acquire_inflight) ----
#[test]
fn first_caller_becomes_leader() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key = ("test.com".to_string(), QueryType::A);
let d = acquire_inflight(&map, key.clone());
assert!(matches!(d, Disposition::Leader(_)));
assert_eq!(map.lock().unwrap().len(), 1);
}
#[test]
fn second_caller_becomes_follower() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key = ("test.com".to_string(), QueryType::A);
let _leader = acquire_inflight(&map, key.clone());
let follower = acquire_inflight(&map, key);
assert!(matches!(follower, Disposition::Follower(_)));
// Map still has exactly 1 entry — follower subscribes, doesn't insert
assert_eq!(map.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn leader_broadcast_reaches_follower() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key = ("test.com".to_string(), QueryType::A);
let leader = acquire_inflight(&map, key.clone());
let follower = acquire_inflight(&map, key);
let tx = match leader {
Disposition::Leader(tx) => tx,
_ => panic!("expected leader"),
};
let mut rx = match follower {
Disposition::Follower(rx) => rx,
_ => panic!("expected follower"),
};
let mut resp = DnsPacket::new();
resp.header.id = 42;
resp.answers.push(DnsRecord::A {
domain: "test.com".into(),
addr: Ipv4Addr::new(1, 2, 3, 4),
ttl: 300,
});
let _ = tx.send(Some(resp));
let received = rx.recv().await.unwrap().unwrap();
assert_eq!(received.header.id, 42);
assert_eq!(received.answers.len(), 1);
}
#[tokio::test]
async fn leader_none_signals_failure_to_follower() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key = ("test.com".to_string(), QueryType::A);
let leader = acquire_inflight(&map, key.clone());
let follower = acquire_inflight(&map, key);
let tx = match leader {
Disposition::Leader(tx) => tx,
_ => panic!("expected leader"),
};
let mut rx = match follower {
Disposition::Follower(rx) => rx,
_ => panic!("expected follower"),
};
let _ = tx.send(None);
assert!(rx.recv().await.unwrap().is_none());
}
#[tokio::test]
async fn multiple_followers_all_receive_via_acquire() {
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
let key = ("multi.com".to_string(), QueryType::A);
let leader = acquire_inflight(&map, key.clone());
let f1 = acquire_inflight(&map, key.clone());
let f2 = acquire_inflight(&map, key.clone());
let f3 = acquire_inflight(&map, key);
let tx = match leader {
Disposition::Leader(tx) => tx,
_ => panic!("expected leader"),
};
let mut resp = DnsPacket::new();
resp.answers.push(DnsRecord::A {
domain: "multi.com".into(),
addr: Ipv4Addr::new(10, 0, 0, 1),
ttl: 60,
});
let _ = tx.send(Some(resp));
for f in [f1, f2, f3] {
let mut rx = match f {
Disposition::Follower(rx) => rx,
_ => panic!("expected follower"),
};
let r = rx.recv().await.unwrap().unwrap();
assert_eq!(r.answers.len(), 1);
}
}
// ---- Integration: resolve_coalesced with mock futures ----
fn mock_response(domain: &str) -> DnsPacket {
let mut resp = DnsPacket::new();
resp.header.response = true;
resp.header.rescode = ResultCode::NOERROR;
resp.answers.push(DnsRecord::A {
domain: domain.to_string(),
addr: Ipv4Addr::new(10, 0, 0, 1),
ttl: 300,
});
resp
}
#[tokio::test]
async fn concurrent_queries_coalesce_to_single_resolution() {
let inflight = Arc::new(Mutex::new(HashMap::new()));
let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let mut handles = Vec::new();
for i in 0..5u16 {
let count = resolve_count.clone();
let inf = inflight.clone();
let key = ("coalesce.test".to_string(), QueryType::A);
let query = DnsPacket::query(100 + i, "coalesce.test", QueryType::A);
handles.push(tokio::spawn(async move {
resolve_coalesced(&inf, key, &query, || async {
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(mock_response("coalesce.test"))
})
.await
}));
}
let mut paths = Vec::new();
for h in handles {
let (_, path, _) = h.await.unwrap();
paths.push(path);
}
let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(actual, 1, "expected 1 resolution, got {}", actual);
let recursive = paths.iter().filter(|p| **p == QueryPath::Recursive).count();
let coalesced = paths.iter().filter(|p| **p == QueryPath::Coalesced).count();
assert_eq!(recursive, 1, "expected 1 RECURSIVE, got {}", recursive);
assert_eq!(coalesced, 4, "expected 4 COALESCED, got {}", coalesced);
assert!(inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn different_qtypes_not_coalesced() {
let inflight = Arc::new(Mutex::new(HashMap::new()));
let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let inf1 = inflight.clone();
let inf2 = inflight.clone();
let count1 = resolve_count.clone();
let count2 = resolve_count.clone();
let query_a = DnsPacket::query(200, "same.domain", QueryType::A);
let query_aaaa = DnsPacket::query(201, "same.domain", QueryType::AAAA);
let h1 = tokio::spawn(async move {
resolve_coalesced(
&inf1,
("same.domain".to_string(), QueryType::A),
&query_a,
|| async {
count1.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(mock_response("same.domain"))
},
)
.await
});
let h2 = tokio::spawn(async move {
resolve_coalesced(
&inf2,
("same.domain".to_string(), QueryType::AAAA),
&query_aaaa,
|| async {
count2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(mock_response("same.domain"))
},
)
.await
});
let (_, path1, _) = h1.await.unwrap();
let (_, path2, _) = h2.await.unwrap();
let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
assert_eq!(actual, 2, "A and AAAA should each resolve, got {}", actual);
assert_eq!(path1, QueryPath::Recursive);
assert_eq!(path2, QueryPath::Recursive);
assert!(inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn inflight_map_cleaned_after_error() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = DnsPacket::query(300, "will-fail.test", QueryType::A);
let (_, path, _) = resolve_coalesced(
&inflight,
("will-fail.test".to_string(), QueryType::A),
&query,
|| async { Err::<DnsPacket, _>("upstream timeout".into()) },
)
.await;
assert_eq!(path, QueryPath::UpstreamError);
assert!(inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn follower_gets_servfail_when_leader_fails() {
let inflight = Arc::new(Mutex::new(HashMap::new()));
let mut handles = Vec::new();
for i in 0..3u16 {
let inf = inflight.clone();
let query = DnsPacket::query(400 + i, "fail.test", QueryType::A);
handles.push(tokio::spawn(async move {
resolve_coalesced(
&inf,
("fail.test".to_string(), QueryType::A),
&query,
|| async {
tokio::time::sleep(Duration::from_millis(200)).await;
Err::<DnsPacket, _>("upstream error".into())
},
)
.await
}));
}
let mut paths = Vec::new();
for h in handles {
let (resp, path, _) = h.await.unwrap();
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
assert_eq!(
resp.questions.len(),
1,
"SERVFAIL must echo question section"
);
assert_eq!(resp.questions[0].name, "fail.test");
paths.push(path);
}
let errors = paths
.iter()
.filter(|p| **p == QueryPath::UpstreamError)
.count();
assert_eq!(errors, 3, "all 3 should be UpstreamError, got {}", errors);
assert!(inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn servfail_leader_includes_question_section() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = DnsPacket::query(500, "question.test", QueryType::A);
let (resp, _, _) = resolve_coalesced(
&inflight,
("question.test".to_string(), QueryType::A),
&query,
|| async { Err::<DnsPacket, _>("fail".into()) },
)
.await;
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
assert_eq!(
resp.questions.len(),
1,
"SERVFAIL must echo question section"
);
assert_eq!(resp.questions[0].name, "question.test");
assert_eq!(resp.questions[0].qtype, QueryType::A);
assert_eq!(resp.header.id, 500);
}
#[tokio::test]
async fn leader_error_preserves_message() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = DnsPacket::query(700, "err-msg.test", QueryType::A);
let (_, path, err) = resolve_coalesced(
&inflight,
("err-msg.test".to_string(), QueryType::A),
&query,
|| async { Err::<DnsPacket, _>("connection refused by upstream".into()) },
)
.await;
assert_eq!(path, QueryPath::UpstreamError);
assert_eq!(
err.as_deref(),
Some("connection refused by upstream"),
"error message must be preserved for logging"
);
}
}

View File

@@ -5,7 +5,6 @@ use log::{debug, trace};
use ring::digest; use ring::digest;
use ring::signature; use ring::signature;
use crate::buffer::BytePacketBuffer;
use crate::cache::{DnsCache, DnssecStatus}; use crate::cache::{DnsCache, DnssecStatus};
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::question::QueryType; use crate::question::QueryType;
@@ -721,29 +720,22 @@ pub fn verify_ds(ds: &DnsRecord, dnskey: &DnsRecord, owner: &str) -> bool {
// -- Canonical wire format -- // -- Canonical wire format --
/// Encode a DNS name in canonical wire form per RFC 4034 §6.2:
/// uncompressed, with ASCII letters lowercased.
///
/// Lowercasing happens *after* escape resolution because `\065` yields
/// `'A'`, which canonical form must convert to `'a'`.
pub fn name_to_wire(name: &str) -> Vec<u8> { pub fn name_to_wire(name: &str) -> Vec<u8> {
let mut buf = BytePacketBuffer::new(); let mut wire = Vec::with_capacity(name.len() + 2);
buf.write_qname(name) if name == "." || name.is_empty() {
.expect("name_to_wire: input must parse as a valid DNS name"); wire.push(0);
let mut wire = buf.filled().to_vec(); return wire;
let mut i = 0;
while i < wire.len() {
let label_len = wire[i] as usize;
if label_len == 0 {
break;
}
i += 1;
let end = i + label_len;
wire[i..end].make_ascii_lowercase();
i = end;
} }
for label in name.split('.') {
if label.is_empty() {
continue;
}
wire.push(label.len() as u8);
for &b in label.as_bytes() {
wire.push(b.to_ascii_lowercase());
}
}
wire.push(0);
wire wire
} }
@@ -1483,23 +1475,6 @@ mod tests {
); );
} }
#[test]
fn name_to_wire_escaped_dot_in_label_is_not_a_separator() {
// `exa\.mple.com` is two labels: `exa.mple` (8 bytes including the 0x2E) and `com`.
let wire = name_to_wire("exa\\.mple.com");
assert_eq!(
wire,
vec![8, b'e', b'x', b'a', b'.', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0]
);
}
#[test]
fn name_to_wire_decimal_escape_is_lowercased() {
// \065 = 'A', must become 'a' in canonical form.
let wire = name_to_wire("\\065bc.com");
assert_eq!(wire, vec![3, b'a', b'b', b'c', 3, b'c', b'o', b'm', 0]);
}
#[test] #[test]
fn parent_zone_cases() { fn parent_zone_cases() {
assert_eq!(parent_zone("example.com"), "com"); assert_eq!(parent_zone("example.com"), "com");

View File

@@ -1,544 +0,0 @@
use std::net::{IpAddr, SocketAddr};
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use log::{debug, error, info, warn};
use rustls::ServerConfig;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::Semaphore;
use tokio_rustls::TlsAcceptor;
use crate::buffer::BytePacketBuffer;
use crate::config::DotConfig;
use crate::ctx::{resolve_query, ServerCtx};
use crate::header::ResultCode;
use crate::packet::DnsPacket;
const MAX_CONNECTIONS: usize = 512;
const IDLE_TIMEOUT: Duration = Duration::from_secs(30);
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
// Matches BytePacketBuffer::BUF_SIZE — RFC 7858 allows up to 65535 but our
// buffer would silently truncate anything larger.
const MAX_MSG_LEN: usize = 4096;
fn dot_alpn() -> Vec<Vec<u8>> {
vec![b"dot".to_vec()]
}
/// Build a TLS ServerConfig for DoT from user-provided cert/key PEM files.
fn load_tls_config(cert_path: &Path, key_path: &Path) -> crate::Result<Arc<ServerConfig>> {
// rustls needs a CryptoProvider installed before ServerConfig::builder().
// The proxy's build_tls_config also does this; we repeat it here because
// running DoT with user-provided certs while the proxy is disabled would
// otherwise panic on first handshake (no default provider).
let _ = rustls::crypto::ring::default_provider().install_default();
let cert_pem = std::fs::read(cert_path)?;
let key_pem = std::fs::read(key_path)?;
let certs: Vec<_> = rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<_, _>>()?;
let key = rustls_pemfile::private_key(&mut &key_pem[..])?
.ok_or("no private key found in key file")?;
let mut config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)?;
config.alpn_protocols = dot_alpn();
Ok(Arc::new(config))
}
/// Build a self-signed DoT TLS config. Can't reuse `ctx.tls_config` (the
/// proxy's shared config) because DoT needs its own ALPN advertisement.
///
/// Pass `proxy_tld` itself as a service name so the cert gets an explicit
/// `{tld}.{tld}` SAN (e.g. "numa.numa") matching the ServerName that
/// setup-phone's mobileconfig sends as SNI. The `*.{tld}` wildcard alone
/// is rejected by strict TLS clients under single-label TLDs (per the
/// note in tls.rs::generate_service_cert).
fn self_signed_tls(ctx: &ServerCtx) -> Option<Arc<ServerConfig>> {
let service_names = [ctx.proxy_tld.clone()];
match crate::tls::build_tls_config(&ctx.proxy_tld, &service_names, dot_alpn(), &ctx.data_dir) {
Ok(cfg) => Some(cfg),
Err(e) => {
warn!(
"DoT: failed to generate self-signed TLS: {} — DoT disabled",
e
);
None
}
}
}
/// Start the DNS-over-TLS listener (RFC 7858).
pub async fn start_dot(ctx: Arc<ServerCtx>, config: &DotConfig) {
let tls_config = match (&config.cert_path, &config.key_path) {
(Some(cert), Some(key)) => match load_tls_config(cert, key) {
Ok(cfg) => cfg,
Err(e) => {
warn!("DoT: failed to load TLS cert/key: {} — DoT disabled", e);
return;
}
},
_ => match self_signed_tls(&ctx) {
Some(cfg) => cfg,
None => return,
},
};
let bind_addr: IpAddr = config
.bind_addr
.parse()
.unwrap_or(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED));
let addr = SocketAddr::new(bind_addr, config.port);
let listener = match TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
warn!("DoT: could not bind {} ({}) — DoT disabled", addr, e);
return;
}
};
info!("DoT listening on {}", addr);
accept_loop(listener, TlsAcceptor::from(tls_config), ctx).await;
}
async fn accept_loop(listener: TcpListener, acceptor: TlsAcceptor, ctx: Arc<ServerCtx>) {
let semaphore = Arc::new(Semaphore::new(MAX_CONNECTIONS));
loop {
let (tcp_stream, remote_addr) = match listener.accept().await {
Ok(conn) => conn,
Err(e) => {
error!("DoT: TCP accept error: {}", e);
// Back off to avoid tight-looping on persistent failures (e.g. fd exhaustion).
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let permit = match semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
debug!("DoT: connection limit reached, rejecting {}", remote_addr);
continue;
}
};
let acceptor = acceptor.clone();
let ctx = Arc::clone(&ctx);
tokio::spawn(async move {
let _permit = permit; // held until task exits
let tls_stream =
match tokio::time::timeout(HANDSHAKE_TIMEOUT, acceptor.accept(tcp_stream)).await {
Ok(Ok(s)) => s,
Ok(Err(e)) => {
debug!("DoT: TLS handshake failed from {}: {}", remote_addr, e);
return;
}
Err(_) => {
debug!("DoT: TLS handshake timeout from {}", remote_addr);
return;
}
};
handle_dot_connection(tls_stream, remote_addr, &ctx).await;
});
}
}
/// Handle a single persistent DoT connection (RFC 7858).
/// 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)
where
S: AsyncReadExt + AsyncWriteExt + Unpin,
{
loop {
// Read 2-byte length prefix (RFC 1035 §4.2.2) with idle timeout
let mut len_buf = [0u8; 2];
let Ok(Ok(_)) = tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut len_buf)).await
else {
break;
};
let msg_len = u16::from_be_bytes(len_buf) as usize;
if msg_len > MAX_MSG_LEN {
debug!("DoT: oversized message {} from {}", msg_len, remote_addr);
break;
}
let mut buffer = BytePacketBuffer::new();
let Ok(Ok(_)) =
tokio::time::timeout(IDLE_TIMEOUT, stream.read_exact(&mut buffer.buf[..msg_len])).await
else {
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) {
Ok(q) => q,
Err(e) => {
warn!("{} | PARSE ERROR | {}", remote_addr, e);
// BytePacketBuffer is zero-initialized, so buf[0..2] reads as 0x0000
// for sub-2-byte messages — harmless FORMERR with id=0.
let query_id = u16::from_be_bytes([buffer.buf[0], buffer.buf[1]]);
let mut resp = DnsPacket::new();
resp.header.id = query_id;
resp.header.response = true;
resp.header.rescode = ResultCode::FORMERR;
if send_response(&mut stream, &resp, remote_addr)
.await
.is_err()
{
break;
}
continue;
}
};
match resolve_query(query.clone(), remote_addr, ctx).await {
Ok(resp_buffer) => {
if write_framed(&mut stream, resp_buffer.filled())
.await
.is_err()
{
break;
}
}
Err(e) => {
warn!("{} | RESOLVE ERROR | {}", remote_addr, e);
// SERVFAIL that echoes the original question section.
let resp = DnsPacket::response_from(&query, ResultCode::SERVFAIL);
if send_response(&mut stream, &resp, remote_addr)
.await
.is_err()
{
break;
}
}
}
}
}
/// Serialize a DNS response and send it framed. Logs serialization failures
/// and returns Err so the caller can tear down the connection.
async fn send_response<S>(
stream: &mut S,
resp: &DnsPacket,
remote_addr: SocketAddr,
) -> std::io::Result<()>
where
S: AsyncWriteExt + Unpin,
{
let mut out_buf = BytePacketBuffer::new();
if resp.write(&mut out_buf).is_err() {
debug!(
"DoT: failed to serialize {:?} response for {}",
resp.header.rescode, remote_addr
);
return Err(std::io::Error::other("serialize failed"));
}
write_framed(stream, out_buf.filled()).await
}
/// Write a DNS message with its 2-byte length prefix, coalesced into one syscall.
/// Bounded by WRITE_TIMEOUT so a stalled reader can't indefinitely hold a worker.
async fn write_framed<S>(stream: &mut S, msg: &[u8]) -> std::io::Result<()>
where
S: AsyncWriteExt + Unpin,
{
let mut out = Vec::with_capacity(2 + msg.len());
out.extend_from_slice(&(msg.len() as u16).to_be_bytes());
out.extend_from_slice(msg);
match tokio::time::timeout(WRITE_TIMEOUT, async {
stream.write_all(&out).await?;
stream.flush().await
})
.await
{
Ok(result) => result,
Err(_) => Err(std::io::Error::other("write timeout")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::{Mutex, RwLock};
use rcgen::{CertificateParams, DnType, KeyPair};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer, ServerName};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use crate::buffer::BytePacketBuffer;
use crate::header::ResultCode;
use crate::packet::DnsPacket;
use crate::question::QueryType;
use crate::record::DnsRecord;
/// Generate a self-signed DoT server config and return its leaf cert DER
/// so callers can build matching client configs with arbitrary ALPN.
fn test_tls_configs() -> (Arc<ServerConfig>, CertificateDer<'static>) {
let _ = rustls::crypto::ring::default_provider().install_default();
// Mirror production self_signed_tls SAN shape: *.numa wildcard plus
// explicit numa.numa apex (the ServerName setup-phone uses as SNI).
let key_pair = KeyPair::generate().unwrap();
let mut params = CertificateParams::default();
params
.distinguished_name
.push(DnType::CommonName, "Numa .numa services");
params.subject_alt_names = vec![
rcgen::SanType::DnsName("*.numa".try_into().unwrap()),
rcgen::SanType::DnsName("numa.numa".try_into().unwrap()),
];
let cert = params.self_signed(&key_pair).unwrap();
let cert_der = CertificateDer::from(cert.der().to_vec());
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
let mut server_config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der.clone()], key_der)
.unwrap();
server_config.alpn_protocols = dot_alpn();
(Arc::new(server_config), cert_der)
}
/// Build a TLS client config that trusts `cert_der` and advertises the
/// given ALPN protocols. Used by tests to vary ALPN per test case.
fn dot_client(
cert_der: &CertificateDer<'static>,
alpn: Vec<Vec<u8>>,
) -> Arc<rustls::ClientConfig> {
let mut root_store = rustls::RootCertStore::empty();
root_store.add(cert_der.clone()).unwrap();
let mut config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
config.alpn_protocols = alpn;
Arc::new(config)
}
/// Spin up a DoT listener with a test TLS config. Returns the bind addr
/// and the leaf cert DER so callers can build clients with arbitrary ALPN.
/// The upstream is pointed at a bound-but-unresponsive UDP socket we own, so
/// any query that escapes to the upstream path times out deterministically
/// (SERVFAIL) regardless of what the host has running on port 53.
async fn spawn_dot_server() -> (SocketAddr, CertificateDer<'static>) {
let (server_tls, cert_der) = test_tls_configs();
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
// Bind an unresponsive upstream and leak it so it lives for the test duration.
let blackhole = Box::leak(Box::new(std::net::UdpSocket::bind("127.0.0.1:0").unwrap()));
let upstream_addr = blackhole.local_addr().unwrap();
let ctx = Arc::new(ServerCtx {
socket,
zone_map: {
let mut m = HashMap::new();
let mut inner = HashMap::new();
inner.insert(
QueryType::A,
vec![DnsRecord::A {
domain: "dot-test.example".to_string(),
addr: std::net::Ipv4Addr::new(10, 0, 0, 1),
ttl: 300,
}],
);
m.insert("dot-test.example".to_string(), inner);
m
},
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
stats: Mutex::new(crate::stats::ServerStats::new()),
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
services: Mutex::new(crate::service_store::ServiceStore::new()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
forwarding_rules: Vec::new(),
upstream: Mutex::new(crate::forward::Upstream::Udp(upstream_addr)),
upstream_auto: false,
upstream_port: 53,
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
timeout: Duration::from_millis(200),
proxy_tld: "numa".to_string(),
proxy_tld_suffix: ".numa".to_string(),
lan_enabled: false,
config_path: String::new(),
config_found: false,
config_dir: std::path::PathBuf::from("/tmp"),
data_dir: std::path::PathBuf::from("/tmp"),
tls_config: Some(arc_swap::ArcSwap::from(server_tls)),
upstream_mode: crate::config::UpstreamMode::Forward,
root_hints: Vec::new(),
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
inflight: Mutex::new(HashMap::new()),
dnssec_enabled: false,
dnssec_strict: false,
health_meta: crate::health::HealthMeta::test_fixture(),
ca_pem: None,
});
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let tls_config = Arc::clone(&*ctx.tls_config.as_ref().unwrap().load());
let acceptor = TlsAcceptor::from(tls_config);
tokio::spawn(accept_loop(listener, acceptor, ctx));
(addr, cert_der)
}
/// Open a TLS connection to the DoT server and return the stream.
/// Uses SNI "numa.numa" to mirror what setup-phone's mobileconfig sends.
async fn dot_connect(
addr: SocketAddr,
client_config: &Arc<rustls::ClientConfig>,
) -> tokio_rustls::client::TlsStream<tokio::net::TcpStream> {
let connector = tokio_rustls::TlsConnector::from(Arc::clone(client_config));
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
connector
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
.await
.unwrap()
}
/// Send a DNS query over a DoT stream and read the response.
async fn dot_exchange(
stream: &mut tokio_rustls::client::TlsStream<tokio::net::TcpStream>,
query: &DnsPacket,
) -> DnsPacket {
let mut buf = BytePacketBuffer::new();
query.write(&mut buf).unwrap();
let msg = buf.filled();
let mut out = Vec::with_capacity(2 + msg.len());
out.extend_from_slice(&(msg.len() as u16).to_be_bytes());
out.extend_from_slice(msg);
stream.write_all(&out).await.unwrap();
let mut len_buf = [0u8; 2];
stream.read_exact(&mut len_buf).await.unwrap();
let resp_len = u16::from_be_bytes(len_buf) as usize;
let mut data = vec![0u8; resp_len];
stream.read_exact(&mut data).await.unwrap();
let mut resp_buf = BytePacketBuffer::from_bytes(&data);
DnsPacket::from_buffer(&mut resp_buf).unwrap()
}
#[tokio::test]
async fn dot_resolves_local_zone() {
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, dot_alpn());
let mut stream = dot_connect(addr, &client_config).await;
let query = DnsPacket::query(0x1234, "dot-test.example", QueryType::A);
let resp = dot_exchange(&mut stream, &query).await;
assert_eq!(resp.header.id, 0x1234);
assert!(resp.header.response);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
assert_eq!(resp.answers.len(), 1);
match &resp.answers[0] {
DnsRecord::A { domain, addr, ttl } => {
assert_eq!(domain, "dot-test.example");
assert_eq!(*addr, std::net::Ipv4Addr::new(10, 0, 0, 1));
assert_eq!(*ttl, 300);
}
other => panic!("expected A record, got {:?}", other),
}
}
#[tokio::test]
async fn dot_multiple_queries_on_persistent_connection() {
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, dot_alpn());
let mut stream = dot_connect(addr, &client_config).await;
for i in 0..3u16 {
let query = DnsPacket::query(0xA000 + i, "dot-test.example", QueryType::A);
let resp = dot_exchange(&mut stream, &query).await;
assert_eq!(resp.header.id, 0xA000 + i);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
assert_eq!(resp.answers.len(), 1);
}
}
#[tokio::test]
async fn dot_nxdomain_for_unknown() {
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, dot_alpn());
let mut stream = dot_connect(addr, &client_config).await;
let query = DnsPacket::query(0xBEEF, "nonexistent.test", QueryType::A);
let resp = dot_exchange(&mut stream, &query).await;
assert_eq!(resp.header.id, 0xBEEF);
assert!(resp.header.response);
// Query goes to the blackhole upstream which never replies → SERVFAIL.
// The SERVFAIL response echoes the question section.
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
assert_eq!(resp.questions.len(), 1);
assert_eq!(resp.questions[0].name, "nonexistent.test");
}
#[tokio::test]
async fn dot_negotiates_alpn() {
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, dot_alpn());
let stream = dot_connect(addr, &client_config).await;
let (_io, conn) = stream.get_ref();
assert_eq!(conn.alpn_protocol(), Some(&b"dot"[..]));
}
#[tokio::test]
async fn dot_rejects_non_dot_alpn() {
// Cross-protocol confusion defense: a client that only offers "h2"
// (e.g. an HTTP/2 client mistakenly hitting :853) must not complete
// a TLS handshake with the DoT server. Verifies the rustls server
// sends `no_application_protocol` rather than silently negotiating.
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, vec![b"h2".to_vec()]);
let connector = tokio_rustls::TlsConnector::from(client_config);
let tcp = tokio::net::TcpStream::connect(addr).await.unwrap();
let result = connector
.connect(ServerName::try_from("numa.numa").unwrap(), tcp)
.await;
assert!(
result.is_err(),
"DoT server must reject ALPN that doesn't include \"dot\""
);
}
#[tokio::test]
async fn dot_concurrent_connections() {
let (addr, cert_der) = spawn_dot_server().await;
let client_config = dot_client(&cert_der, dot_alpn());
let mut handles = Vec::new();
for i in 0..5u16 {
let cfg = Arc::clone(&client_config);
handles.push(tokio::spawn(async move {
let mut stream = dot_connect(addr, &cfg).await;
let query = DnsPacket::query(0xC000 + i, "dot-test.example", QueryType::A);
let resp = dot_exchange(&mut stream, &query).await;
assert_eq!(resp.header.id, 0xC000 + i);
assert_eq!(resp.header.rescode, ResultCode::NOERROR);
assert_eq!(resp.answers.len(), 1);
}));
}
for h in handles {
h.await.unwrap();
}
}
}

View File

@@ -141,7 +141,7 @@ mod tests {
use std::future::IntoFuture; use std::future::IntoFuture;
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::question::QueryType; use crate::question::{DnsQuestion, QueryType};
use crate::record::DnsRecord; use crate::record::DnsRecord;
#[test] #[test]
@@ -160,7 +160,12 @@ mod tests {
} }
fn make_query() -> DnsPacket { fn make_query() -> DnsPacket {
DnsPacket::query(0xABCD, "example.com", QueryType::A) let mut q = DnsPacket::new();
q.header.id = 0xABCD;
q.header.recursion_desired = true;
q.questions
.push(DnsQuestion::new("example.com".to_string(), QueryType::A));
q
} }
fn make_response(query: &DnsPacket) -> DnsPacket { fn make_response(query: &DnsPacket) -> DnsPacket {

View File

@@ -1,254 +0,0 @@
//! Health metadata and `/health` response shape, shared between the main
//! HTTP API and the mobile API.
//!
//! The static fields (version, hostname, DoT config, CA fingerprint,
//! feature list) are computed once at startup and stored in [`HealthMeta`]
//! on `ServerCtx`. Per-request fields (uptime, LAN IP) are computed live.
//! Both handlers call [`HealthResponse::build`] to assemble the JSON
//! response from `HealthMeta` + live inputs.
//!
//! JSON schema is documented in `docs/implementation/ios-companion-app.md`
//! §4.2. The iOS companion app's `HealthInfo` struct is the canonical
//! consumer; any change to this response must keep that struct decoding
//! cleanly (all consumed fields are optional on the Swift side, but
//! `lan_ip` is load-bearing for the pipeline).
use std::net::Ipv4Addr;
use std::path::Path;
use std::time::Instant;
use ring::digest::{digest, SHA256};
use serde::Serialize;
/// Immutable health metadata cached on `ServerCtx`. Built once at startup
/// from config + file-system state (CA cert).
#[derive(Clone)]
pub struct HealthMeta {
pub version: &'static str,
pub hostname: String,
pub sni: String,
pub dot_enabled: bool,
pub dot_port: u16,
pub api_port: u16,
pub ca_fingerprint_sha256: Option<String>,
pub features: Vec<String>,
pub started_at: Instant,
}
impl HealthMeta {
/// Minimal `HealthMeta` for unit tests that construct a `ServerCtx`
/// without needing the real startup flow (CA file reads, hostname
/// detection, etc.). Deterministic values so test JSON assertions
/// stay stable.
#[cfg(test)]
pub fn test_fixture() -> Self {
HealthMeta {
version: env!("CARGO_PKG_VERSION"),
hostname: "test-host".to_string(),
sni: "numa.numa".to_string(),
dot_enabled: false,
dot_port: 853,
api_port: 8765,
ca_fingerprint_sha256: None,
features: vec![],
started_at: Instant::now(),
}
}
/// Build a new HealthMeta from config + startup-time environment.
/// Call once at server boot; the returned value is cheap to clone
/// (small number of short strings) and lives on `ServerCtx`.
///
/// The argument count is deliberate — each flag corresponds to a
/// specific config value and is clearly named at the call site.
/// Collapsing into a struct hides nothing meaningful for a one-call
/// initializer.
#[allow(clippy::too_many_arguments)]
pub fn build(
data_dir: &Path,
dot_enabled: bool,
dot_port: u16,
api_port: u16,
dnssec_enabled: bool,
recursive_enabled: bool,
mdns_enabled: bool,
blocking_enabled: bool,
) -> Self {
let ca_path = data_dir.join("ca.pem");
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
let mut features = Vec::new();
if dot_enabled {
features.push("dot".to_string());
}
if recursive_enabled {
features.push("recursive".to_string());
}
if blocking_enabled {
features.push("blocking".to_string());
}
if mdns_enabled {
features.push("mdns".to_string());
}
if dnssec_enabled {
features.push("dnssec".to_string());
}
HealthMeta {
version: env!("CARGO_PKG_VERSION"),
hostname: crate::hostname(),
sni: "numa.numa".to_string(),
dot_enabled,
dot_port,
api_port,
ca_fingerprint_sha256,
features,
started_at: Instant::now(),
}
}
}
/// JSON response shape returned by `GET /health` on both main and mobile APIs.
///
/// Fields are organized to match the iOS companion app's
/// `HealthInfo` Swift struct — see `ios-companion-app.md` §4.2.
#[derive(Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub version: &'static str,
pub uptime_secs: u64,
pub hostname: String,
pub lan_ip: Option<String>,
pub sni: String,
pub dot: DotBlock,
pub api: ApiBlock,
pub ca: CaBlock,
pub features: Vec<String>,
}
#[derive(Serialize)]
pub struct DotBlock {
pub enabled: bool,
pub port: Option<u16>,
}
#[derive(Serialize)]
pub struct ApiBlock {
pub port: u16,
}
#[derive(Serialize)]
pub struct CaBlock {
pub present: bool,
pub fingerprint_sha256: Option<String>,
}
impl HealthResponse {
/// Assemble a fresh `HealthResponse` from the cached metadata and
/// the current LAN IP (which may change across network transitions).
/// Pass `None` for `lan_ip` if detection fails — the response still
/// returns 200 OK, just without the LAN address.
pub fn build(meta: &HealthMeta, lan_ip: Option<Ipv4Addr>) -> Self {
HealthResponse {
status: "ok",
version: meta.version,
uptime_secs: meta.started_at.elapsed().as_secs(),
hostname: meta.hostname.clone(),
lan_ip: lan_ip.map(|ip| ip.to_string()),
sni: meta.sni.clone(),
dot: DotBlock {
enabled: meta.dot_enabled,
port: if meta.dot_enabled {
Some(meta.dot_port)
} else {
None
},
},
api: ApiBlock {
port: meta.api_port,
},
ca: CaBlock {
present: meta.ca_fingerprint_sha256.is_some(),
fingerprint_sha256: meta.ca_fingerprint_sha256.clone(),
},
features: meta.features.clone(),
}
}
}
/// Read the CA cert at `ca_path` and return its SHA-256 fingerprint as a
/// lowercase hex string, or None if the file doesn't exist or can't be read.
///
/// Hashes the raw PEM bytes for simplicity. A more canonical SPKI-based
/// fingerprint would require parsing the PEM → DER → extracting
/// SubjectPublicKeyInfo, which adds complexity without meaningful benefit
/// for our use case (the iOS app uses the fingerprint only for display
/// and to detect rotation).
fn compute_ca_fingerprint(ca_path: &Path) -> Option<String> {
let pem = std::fs::read(ca_path).ok()?;
let hash = digest(&SHA256, &pem);
let hex: String = hash.as_ref().iter().map(|b| format!("{:02x}", b)).collect();
Some(hex)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn health_response_contains_required_fields() {
let meta = HealthMeta {
version: "0.10.0",
hostname: "test-host".to_string(),
sni: "numa.numa".to_string(),
dot_enabled: true,
dot_port: 853,
api_port: 8765,
ca_fingerprint_sha256: Some("abcd1234".to_string()),
features: vec!["dot".to_string(), "dnssec".to_string()],
started_at: Instant::now(),
};
let response = HealthResponse::build(&meta, Some(Ipv4Addr::new(192, 168, 1, 50)));
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"status\":\"ok\""));
assert!(json.contains("\"version\":\"0.10.0\""));
assert!(json.contains("\"hostname\":\"test-host\""));
assert!(json.contains("\"lan_ip\":\"192.168.1.50\""));
assert!(json.contains("\"sni\":\"numa.numa\""));
assert!(json.contains("\"port\":853"));
assert!(json.contains("\"port\":8765"));
assert!(json.contains("\"fingerprint_sha256\":\"abcd1234\""));
assert!(json.contains("\"features\":[\"dot\",\"dnssec\"]"));
}
#[test]
fn health_response_omits_dot_port_when_disabled() {
let meta = HealthMeta {
version: "0.10.0",
hostname: "t".to_string(),
sni: "numa.numa".to_string(),
dot_enabled: false,
dot_port: 853,
api_port: 8765,
ca_fingerprint_sha256: None,
features: vec![],
started_at: Instant::now(),
};
let response = HealthResponse::build(&meta, None);
let json = serde_json::to_string(&response).unwrap();
assert!(json.contains("\"enabled\":false"));
assert!(json.contains("\"dot\":{\"enabled\":false,\"port\":null}"));
assert!(json.contains("\"present\":false"));
assert!(json.contains("\"lan_ip\":null"));
}
#[test]
fn ca_fingerprint_returns_none_for_missing_file() {
let fp = compute_ca_fingerprint(Path::new("/nonexistent/ca.pem"));
assert!(fp.is_none());
}
}

View File

@@ -9,7 +9,6 @@ use crate::buffer::BytePacketBuffer;
use crate::config::LanConfig; use crate::config::LanConfig;
use crate::ctx::ServerCtx; use crate::ctx::ServerCtx;
use crate::header::DnsHeader; use crate::header::DnsHeader;
use crate::health::HealthMeta;
use crate::question::{DnsQuestion, QueryType}; use crate::question::{DnsQuestion, QueryType};
// --- Constants --- // --- Constants ---
@@ -19,18 +18,6 @@ const MDNS_PORT: u16 = 5353;
const SERVICE_TYPE: &str = "_numa._tcp.local"; const SERVICE_TYPE: &str = "_numa._tcp.local";
const MDNS_TTL: u32 = 120; const MDNS_TTL: u32 = 120;
// TXT record key prefixes (including the trailing `=`). Shared between
// the sender (`build_announcement`) and the receiver (`parse_mdns_response`)
// to prevent drift — both sides match on the same literal, not on two
// independent string constants that could diverge.
const TXT_SERVICES: &str = "services=";
const TXT_ID: &str = "id=";
const TXT_VERSION: &str = "version=";
const TXT_API_PORT: &str = "api_port=";
const TXT_PROTO: &str = "proto=";
const TXT_DOT_PORT: &str = "dot_port=";
const TXT_CA_FP: &str = "ca_fp=";
// --- Peer Store --- // --- Peer Store ---
pub struct PeerStore { pub struct PeerStore {
@@ -110,16 +97,14 @@ pub fn detect_lan_ip() -> Option<Ipv4Addr> {
} }
} }
/// Short hostname for mDNS instance names (`<short>._numa._tcp.local`).
/// Truncates at the first `.` so `macbook-pro.local` becomes `macbook-pro`.
/// Uses the shared `crate::hostname()` helper as the source.
fn get_hostname() -> String { fn get_hostname() -> String {
crate::hostname() std::process::Command::new("hostname")
.split('.') .output()
.next() .ok()
.filter(|s| !s.is_empty()) .and_then(|o| String::from_utf8(o.stdout).ok())
.unwrap_or("numa") .map(|h| h.trim().split('.').next().unwrap_or("numa").to_string())
.to_string() .filter(|h| !h.is_empty())
.unwrap_or_else(|| "numa".to_string())
} }
/// Generate a per-process instance ID for self-filtering on multi-instance hosts /// Generate a per-process instance ID for self-filtering on multi-instance hosts
@@ -183,22 +168,13 @@ pub async fn start_lan_discovery(ctx: Arc<ServerCtx>, config: &LanConfig) {
.map(|e| (e.name.clone(), e.target_port)) .map(|e| (e.name.clone(), e.target_port))
.collect() .collect()
}; };
// Note: we always announce ourselves, even when the if services.is_empty() {
// services list is empty. The announcement still carries continue;
// the mobile API port + version + CA fingerprint in TXT, }
// which is what the iOS companion app browses for via
// NWBrowser on `_numa._tcp.local`. Other Numa peers
// receive these empty-services announcements too and
// correctly ignore them in parse_mdns_response (the
// receiver only processes when services is non-empty).
let current_ip = *sender_ctx.lan_ip.lock().unwrap(); let current_ip = *sender_ctx.lan_ip.lock().unwrap();
if let Ok(pkt) = build_announcement( if let Ok(pkt) =
&sender_hostname, build_announcement(&sender_hostname, current_ip, &services, &sender_instance_id)
current_ip, {
&services,
&sender_instance_id,
&sender_ctx.health_meta,
) {
let _ = sender_socket.send_to(pkt.filled(), dest).await; let _ = sender_socket.send_to(pkt.filled(), dest).await;
} }
} }
@@ -264,7 +240,6 @@ fn build_announcement(
ip: Ipv4Addr, ip: Ipv4Addr,
services: &[(String, u16)], services: &[(String, u16)],
inst_id: &str, inst_id: &str,
meta: &HealthMeta,
) -> crate::Result<BytePacketBuffer> { ) -> crate::Result<BytePacketBuffer> {
let mut buf = BytePacketBuffer::new(); let mut buf = BytePacketBuffer::new();
let instance_name = format!("{}._numa._tcp.local", hostname); let instance_name = format!("{}._numa._tcp.local", hostname);
@@ -285,11 +260,7 @@ fn build_announcement(
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// SRV: <instance>._numa._tcp.local → <hostname>.local // SRV: <instance>._numa._tcp.local → <hostname>.local
// Port = mobile API port, which is what the iOS companion app resolves // Port in SRV is informational; actual service ports are in TXT
// the SRV record for. Legacy Numa peers don't read the SRV port (see
// parse_mdns_response — it only uses TXT services= for peer discovery),
// so changing the SRV port from "first service's port" to the mobile
// API port is backwards compatible.
write_record_header( write_record_header(
&mut buf, &mut buf,
&instance_name, &instance_name,
@@ -302,13 +273,11 @@ fn build_announcement(
let rdata_start = buf.pos(); let rdata_start = buf.pos();
buf.write_u16(0)?; // priority buf.write_u16(0)?; // priority
buf.write_u16(0)?; // weight buf.write_u16(0)?; // weight
buf.write_u16(meta.api_port)?; // mobile API port, for iOS companion app buf.write_u16(services.first().map(|(_, p)| *p).unwrap_or(0))?; // first service port for SRV display
buf.write_qname(&host_local)?; buf.write_qname(&host_local)?;
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// TXT: legacy peer-discovery entries (services, id) + enriched entries // TXT: services + instance ID for self-filtering
// for the iOS companion app (version, api_port, proto, dot_port, ca_fp).
// All in one TXT RRset per mDNS convention.
write_record_header( write_record_header(
&mut buf, &mut buf,
&instance_name, &instance_name,
@@ -324,21 +293,8 @@ fn build_announcement(
.map(|(name, port)| format!("{}:{}", name, port)) .map(|(name, port)| format!("{}:{}", name, port))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(","); .join(",");
// Legacy peer-discovery entries (consumed by parse_mdns_response) write_txt_string(&mut buf, &format!("services={}", svc_str))?;
write_txt_string(&mut buf, &format!("{}{}", TXT_SERVICES, svc_str))?; write_txt_string(&mut buf, &format!("id={}", inst_id))?;
write_txt_string(&mut buf, &format!("{}{}", TXT_ID, inst_id))?;
// Enriched entries (consumed by the iOS/Android companion apps)
write_txt_string(&mut buf, &format!("{}{}", TXT_VERSION, meta.version))?;
write_txt_string(&mut buf, &format!("{}{}", TXT_API_PORT, meta.api_port))?;
if meta.dot_enabled {
write_txt_string(&mut buf, &format!("{}dot", TXT_PROTO))?;
write_txt_string(&mut buf, &format!("{}{}", TXT_DOT_PORT, meta.dot_port))?;
} else {
write_txt_string(&mut buf, &format!("{}plain", TXT_PROTO))?;
}
if let Some(fp) = &meta.ca_fingerprint_sha256 {
write_txt_string(&mut buf, &format!("{}{}", TXT_CA_FP, fp))?;
}
patch_rdlen(&mut buf, rdlen_pos, rdata_start)?; patch_rdlen(&mut buf, rdlen_pos, rdata_start)?;
// A: <hostname>.local → IP // A: <hostname>.local → IP
@@ -452,7 +408,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
break; break;
} }
if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) { if let Ok(txt) = std::str::from_utf8(&data[pos..pos + txt_len]) {
if let Some(val) = txt.strip_prefix(TXT_SERVICES) { if let Some(val) = txt.strip_prefix("services=") {
let svcs: Vec<(String, u16)> = val let svcs: Vec<(String, u16)> = val
.split(',') .split(',')
.filter_map(|s| { .filter_map(|s| {
@@ -465,7 +421,7 @@ fn parse_mdns_response(data: &[u8]) -> Option<MdnsAnnouncement> {
if !svcs.is_empty() { if !svcs.is_empty() {
txt_services = Some(svcs); txt_services = Some(svcs);
} }
} else if let Some(id) = txt.strip_prefix(TXT_ID) { } else if let Some(id) = txt.strip_prefix("id=") {
peer_instance_id = Some(id.to_string()); peer_instance_id = Some(id.to_string());
} }
} }

View File

@@ -5,13 +5,9 @@ pub mod cache;
pub mod config; pub mod config;
pub mod ctx; pub mod ctx;
pub mod dnssec; pub mod dnssec;
pub mod dot;
pub mod forward; pub mod forward;
pub mod header; pub mod header;
pub mod health;
pub mod lan; pub mod lan;
pub mod mobile_api;
pub mod mobileconfig;
pub mod override_store; pub mod override_store;
pub mod packet; pub mod packet;
pub mod proxy; pub mod proxy;
@@ -20,7 +16,6 @@ pub mod question;
pub mod record; pub mod record;
pub mod recursive; pub mod recursive;
pub mod service_store; pub mod service_store;
pub mod setup_phone;
pub mod srtt; pub mod srtt;
pub mod stats; pub mod stats;
pub mod system_dns; pub mod system_dns;
@@ -29,25 +24,8 @@ pub mod tls;
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>;
/// Detect the machine hostname via the `hostname` command. Returns the
/// full hostname (e.g., `macbook-pro.local`), or `"numa"` if the command
/// fails. Call sites that need the short form (e.g., mDNS instance
/// names) should truncate at the first `.`.
pub fn hostname() -> String {
std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())
.map(|h| h.trim().to_string())
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "numa".to_string())
}
/// Shared config directory for persistent data (services.json, etc). /// Shared config directory for persistent data (services.json, etc).
/// Unix users: ~/.config/numa/ /// Unix: ~/.config/numa/ (or /usr/local/var/numa/ when running as root daemon)
/// Linux root daemon: /var/lib/numa (FHS) — falls back to /usr/local/var/numa
/// if a pre-v0.10.1 install already lives there.
/// macOS root daemon: /usr/local/var/numa (Homebrew prefix)
/// Windows: %APPDATA%\numa /// Windows: %APPDATA%\numa
pub fn config_dir() -> std::path::PathBuf { pub fn config_dir() -> std::path::PathBuf {
#[cfg(windows)] #[cfg(windows)]
@@ -84,15 +62,11 @@ fn config_dir_unix() -> std::path::PathBuf {
} }
// Running as root daemon (launchd/systemd) — use system-wide path // Running as root daemon (launchd/systemd) — use system-wide path
daemon_data_dir() std::path::PathBuf::from("/usr/local/var/numa")
} }
/// Default system-wide data directory for TLS certs. Overridable via /// System-wide data directory for TLS certs.
/// `[server] data_dir = "..."` in numa.toml — this function only provides /// Unix: /usr/local/var/numa
/// the fallback when the config doesn't set it.
/// Linux: /var/lib/numa (FHS) — falls back to /usr/local/var/numa if a
/// pre-v0.10.1 install already has data there.
/// macOS: /usr/local/var/numa (Homebrew prefix)
/// Windows: %PROGRAMDATA%\numa /// Windows: %PROGRAMDATA%\numa
pub fn data_dir() -> std::path::PathBuf { pub fn data_dir() -> std::path::PathBuf {
#[cfg(windows)] #[cfg(windows)]
@@ -104,62 +78,6 @@ pub fn data_dir() -> std::path::PathBuf {
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
daemon_data_dir()
}
}
/// Resolve the system-wide data directory for the running platform.
/// Honors backwards compatibility with pre-v0.10.1 installs that still
/// have their CA cert + services.json under `/usr/local/var/numa`.
#[cfg(not(windows))]
fn daemon_data_dir() -> std::path::PathBuf {
#[cfg(target_os = "linux")]
{
std::path::PathBuf::from(resolve_linux_data_dir(
std::path::Path::new("/usr/local/var/numa").exists(),
std::path::Path::new("/var/lib/numa").exists(),
))
}
#[cfg(target_os = "macos")]
{
// macOS uses the Homebrew prefix convention; no FHS migration needed.
std::path::PathBuf::from("/usr/local/var/numa") std::path::PathBuf::from("/usr/local/var/numa")
} }
} }
/// Extracted as a pure function so the migration logic is unit-testable
/// without touching the real filesystem.
#[cfg(any(target_os = "linux", test))]
fn resolve_linux_data_dir(legacy_exists: bool, fhs_exists: bool) -> &'static str {
if legacy_exists && !fhs_exists {
"/usr/local/var/numa"
} else {
"/var/lib/numa"
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linux_data_dir_fresh_install_uses_fhs() {
assert_eq!(resolve_linux_data_dir(false, false), "/var/lib/numa");
}
#[test]
fn linux_data_dir_upgrading_install_keeps_legacy() {
// Migration must keep legacy so the user doesn't lose their CA on upgrade.
assert_eq!(resolve_linux_data_dir(true, false), "/usr/local/var/numa");
}
#[test]
fn linux_data_dir_after_migration_uses_fhs() {
assert_eq!(resolve_linux_data_dir(true, true), "/var/lib/numa");
}
#[test]
fn linux_data_dir_only_fhs_uses_fhs() {
assert_eq!(resolve_linux_data_dir(false, true), "/var/lib/numa");
}
}

View File

@@ -17,12 +17,10 @@ use numa::query_log::QueryLog;
use numa::service_store::ServiceStore; use numa::service_store::ServiceStore;
use numa::stats::ServerStats; use numa::stats::ServerStats;
use numa::system_dns::{ use numa::system_dns::{
discover_system_dns, install_service, restart_service, service_status, uninstall_service, discover_system_dns, install_service, install_system_dns, restart_service, service_status,
uninstall_service, uninstall_system_dns,
}; };
const QUAD9_IP: &str = "9.9.9.9";
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
#[tokio::main] #[tokio::main]
async fn main() -> numa::Result<()> { async fn main() -> numa::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
@@ -33,12 +31,12 @@ async fn main() -> numa::Result<()> {
let arg1 = std::env::args().nth(1).unwrap_or_default(); let arg1 = std::env::args().nth(1).unwrap_or_default();
match arg1.as_str() { match arg1.as_str() {
"install" => { "install" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n"); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n");
return install_service().map_err(|e| e.into()); return install_system_dns().map_err(|e| e.into());
} }
"uninstall" => { "uninstall" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n"); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n");
return uninstall_service().map_err(|e| e.into()); return uninstall_system_dns().map_err(|e| e.into());
} }
"service" => { "service" => {
let sub = std::env::args().nth(2).unwrap_or_default(); let sub = std::env::args().nth(2).unwrap_or_default();
@@ -54,9 +52,6 @@ async fn main() -> numa::Result<()> {
} }
}; };
} }
"setup-phone" => {
return numa::setup_phone::run().await.map_err(|e| e.into());
}
"lan" => { "lan" => {
let sub = std::env::args().nth(2).unwrap_or_default(); let sub = std::env::args().nth(2).unwrap_or_default();
let config_path = std::env::args() let config_path = std::env::args()
@@ -88,27 +83,12 @@ async fn main() -> numa::Result<()> {
eprintln!(" service status Check if the service is running"); eprintln!(" service status Check if the service is running");
eprintln!(" lan on Enable LAN service discovery (mDNS)"); eprintln!(" lan on Enable LAN service discovery (mDNS)");
eprintln!(" lan off Disable LAN service discovery"); eprintln!(" lan off Disable LAN service discovery");
eprintln!(" setup-phone Generate a QR code to install Numa DoT on a phone");
eprintln!(" help Show this help"); eprintln!(" help Show this help");
eprintln!(); eprintln!();
eprintln!("Config path defaults to numa.toml"); eprintln!("Config path defaults to numa.toml");
return Ok(()); return Ok(());
} }
_ => { _ => {}
if !arg1.is_empty()
&& arg1 != "run"
&& !arg1.contains('/')
&& !arg1.contains('\\')
&& !arg1.ends_with(".toml")
{
eprintln!(
"\x1b[1;38;2;192;98;58mNuma\x1b[0m — unknown command: \x1b[1m{}\x1b[0m\n",
arg1
);
eprintln!("Run \x1b[1mnuma help\x1b[0m for a list of commands.");
std::process::exit(1);
}
}
} }
let config_path = if arg1.is_empty() || arg1 == "run" { let config_path = if arg1.is_empty() || arg1 == "run" {
@@ -127,81 +107,32 @@ async fn main() -> numa::Result<()> {
// Discover system DNS in a single pass (upstream + forwarding rules) // Discover system DNS in a single pass (upstream + forwarding rules)
let system_dns = discover_system_dns(); let system_dns = discover_system_dns();
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints); let upstream_addr = if config.upstream.address.is_empty() {
system_dns
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode { .default_upstream
numa::config::UpstreamMode::Auto => { .or_else(numa::system_dns::detect_dhcp_dns)
info!("auto mode: probing recursive resolution..."); .unwrap_or_else(|| {
if numa::recursive::probe_recursive(&root_hints).await { info!("could not detect system DNS, falling back to Quad9 DoH");
info!("recursive probe succeeded — self-sovereign mode"); "https://dns.quad9.net/dns-query".to_string()
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap()); })
( } else {
numa::config::UpstreamMode::Recursive, config.upstream.address.clone()
false,
dummy,
"recursive (root hints)".to_string(),
)
} else {
log::warn!("recursive probe failed — falling back to Quad9 DoH");
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.unwrap_or_default();
let url = DOH_FALLBACK.to_string();
let label = url.clone();
(
numa::config::UpstreamMode::Forward,
false,
Upstream::Doh { url, client },
label,
)
}
}
numa::config::UpstreamMode::Recursive => {
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
(
numa::config::UpstreamMode::Recursive,
false,
dummy,
"recursive (root hints)".to_string(),
)
}
numa::config::UpstreamMode::Forward => {
let upstream_addr = if config.upstream.address.is_empty() {
system_dns
.default_upstream
.or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| {
info!("could not detect system DNS, falling back to Quad9 DoH");
DOH_FALLBACK.to_string()
})
} else {
config.upstream.address.clone()
};
let upstream: Upstream = if upstream_addr.starts_with("https://") {
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.unwrap_or_default();
Upstream::Doh {
url: upstream_addr,
client,
}
} else {
let addr: SocketAddr =
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
Upstream::Udp(addr)
};
let label = upstream.to_string();
(
numa::config::UpstreamMode::Forward,
config.upstream.address.is_empty(),
upstream,
label,
)
}
}; };
let upstream: Upstream = if upstream_addr.starts_with("https://") {
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.unwrap_or_default();
Upstream::Doh {
url: upstream_addr,
client,
}
} else {
let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
Upstream::Udp(addr)
};
let upstream_label = upstream.to_string();
let api_port = config.server.api_port; let api_port = config.server.api_port;
let mut blocklist = BlocklistStore::new(); let mut blocklist = BlocklistStore::new();
@@ -222,30 +153,13 @@ async fn main() -> numa::Result<()> {
let forwarding_rules = system_dns.forwarding_rules; let forwarding_rules = system_dns.forwarding_rules;
// Resolve data_dir from config, falling back to the platform default.
// Used for TLS CA storage below and stored on ServerCtx for runtime use.
let resolved_data_dir = config
.server
.data_dir
.clone()
.unwrap_or_else(numa::data_dir);
// Build initial TLS config before ServerCtx (so ArcSwap is ready at construction) // Build initial TLS config before ServerCtx (so ArcSwap is ready at construction)
let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 { let initial_tls = if config.proxy.enabled && config.proxy.tls_port > 0 {
let service_names = service_store.names(); let service_names = service_store.names();
match numa::tls::build_tls_config( match numa::tls::build_tls_config(&config.proxy.tld, &service_names) {
&config.proxy.tld,
&service_names,
Vec::new(),
&resolved_data_dir,
) {
Ok(tls_config) => Some(ArcSwap::from(tls_config)), Ok(tls_config) => Some(ArcSwap::from(tls_config)),
Err(e) => { Err(e) => {
if let Some(advisory) = numa::tls::try_data_dir_advisory(&e, &resolved_data_dir) { log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
eprint!("{}", advisory);
} else {
log::warn!("TLS setup failed, HTTPS proxy disabled: {}", e);
}
None None
} }
} }
@@ -253,34 +167,8 @@ async fn main() -> numa::Result<()> {
None None
}; };
let health_meta = numa::health::HealthMeta::build(
&resolved_data_dir,
config.dot.enabled,
config.dot.port,
config.mobile.port,
config.dnssec.enabled,
resolved_mode == numa::config::UpstreamMode::Recursive,
config.lan.enabled,
config.blocking.enabled,
);
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
let socket = match UdpSocket::bind(&config.server.bind_addr).await {
Ok(s) => s,
Err(e) => {
if let Some(advisory) =
numa::system_dns::try_port53_advisory(&config.server.bind_addr, &e)
{
eprint!("{}", advisory);
std::process::exit(1);
}
return Err(e.into());
}
};
let ctx = Arc::new(ServerCtx { let ctx = Arc::new(ServerCtx {
socket, socket: UdpSocket::bind(&config.server.bind_addr).await?,
zone_map: build_zone_map(&config.zones)?, zone_map: build_zone_map(&config.zones)?,
cache: RwLock::new(DnsCache::new( cache: RwLock::new(DnsCache::new(
config.cache.max_entries, config.cache.max_entries,
@@ -295,7 +183,7 @@ async fn main() -> numa::Result<()> {
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: Mutex::new(upstream),
upstream_auto, upstream_auto: config.upstream.address.is_empty(),
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),
@@ -309,19 +197,17 @@ async fn main() -> numa::Result<()> {
config_path: resolved_config_path, config_path: resolved_config_path,
config_found, config_found,
config_dir: numa::config_dir(), config_dir: numa::config_dir(),
data_dir: resolved_data_dir, data_dir: numa::data_dir(),
tls_config: initial_tls, tls_config: initial_tls,
upstream_mode: resolved_mode, upstream_mode: config.upstream.mode,
root_hints, root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints),
srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)), srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)),
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: config.dnssec.enabled, dnssec_enabled: config.dnssec.enabled,
dnssec_strict: config.dnssec.strict, dnssec_strict: config.dnssec.strict,
health_meta,
ca_pem,
}); });
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();
// Build banner rows, then size the box to fit the longest value // Build banner rows, then size the box to fit the longest value
let api_url = format!("http://localhost:{}", api_port); let api_url = format!("http://localhost:{}", api_port);
let proxy_label = if config.proxy.enabled { let proxy_label = if config.proxy.enabled {
@@ -421,20 +307,6 @@ async fn main() -> numa::Result<()> {
); );
if let Some(ref label) = proxy_label { if let Some(ref label) = proxy_label {
row("Proxy", g, label); row("Proxy", g, label);
if config.proxy.bind_addr == "127.0.0.1" {
let y = "\x1b[38;2;204;176;59m"; // yellow
row(
"",
y,
&format!(
"⚠ proxy on 127.0.0.1 — .{} not LAN reachable",
config.proxy.tld
),
);
}
}
if config.dot.enabled {
row("DoT", g, &format!("tls://:{}", config.dot.port));
} }
if config.lan.enabled { if config.lan.enabled {
row("LAN", g, "mDNS (_numa._tcp.local)"); row("LAN", g, "mDNS (_numa._tcp.local)");
@@ -502,26 +374,16 @@ async fn main() -> numa::Result<()> {
axum::serve(listener, app).await.unwrap(); axum::serve(listener, app).await.unwrap();
}); });
// Spawn Mobile API listener (read-only subset for iOS/Android companion // Proxy binds 0.0.0.0 when LAN is enabled (cross-machine access), otherwise config value
// apps, LAN-bound by default so phones can reach it). Only idempotent let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled {
// GETs; no state-mutating routes are exposed here regardless of std::net::Ipv4Addr::UNSPECIFIED
// the main API's bind address. } else {
if config.mobile.enabled { config
let mobile_ctx = Arc::clone(&ctx); .proxy
let mobile_bind = config.mobile.bind_addr.clone(); .bind_addr
let mobile_port = config.mobile.port; .parse()
tokio::spawn(async move { .unwrap_or(std::net::Ipv4Addr::LOCALHOST)
if let Err(e) = numa::mobile_api::start(mobile_ctx, mobile_bind, mobile_port).await { };
log::warn!("Mobile API listener failed: {}", e);
}
});
}
let proxy_bind: std::net::Ipv4Addr = config
.proxy
.bind_addr
.parse()
.unwrap_or(std::net::Ipv4Addr::LOCALHOST);
// Spawn HTTP reverse proxy for .numa domains // Spawn HTTP reverse proxy for .numa domains
if config.proxy.enabled { if config.proxy.enabled {
@@ -558,27 +420,11 @@ async fn main() -> numa::Result<()> {
}); });
} }
// Spawn DNS-over-TLS listener (RFC 7858)
if config.dot.enabled {
let dot_ctx = Arc::clone(&ctx);
let dot_config = config.dot.clone();
tokio::spawn(async move {
numa::dot::start_dot(dot_ctx, &dot_config).await;
});
}
// UDP DNS listener // UDP DNS listener
#[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 (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?;
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {
// Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets
continue;
}
Err(e) => return Err(e.into()),
};
let ctx = Arc::clone(&ctx); let ctx = Arc::clone(&ctx);
tokio::spawn(async move { tokio::spawn(async move {
@@ -621,7 +467,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
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(|| "9.9.9.9".to_string());
if let Ok(new_sock) = if let Ok(new_sock) =
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>() format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
{ {

View File

@@ -1,107 +0,0 @@
//! Mobile API — persistent HTTP listener for iOS/Android companion apps.
//!
//! Read-only subset of Numa's HTTP surface served on a separate port
//! (default 8765) bound to the LAN. Unlike the main API on port 5380
//! (which defaults to `127.0.0.1` and serves mutating routes like
//! `DELETE /services/{name}` or `PUT /blocking/toggle`), this listener
//! is safe to expose on the LAN because every route is idempotent and
//! read-only.
//!
//! Routes (all GET):
//!
//! - `/health` — enriched status + metadata, shares the handler with the
//! main API via `crate::api::health`
//! - `/ca.pem` — Numa local CA in PEM form, shares the handler with the
//! main API via `crate::api::serve_ca`
//! - `/mobileconfig` — combined CA + DNS settings profile (Full mode)
//! - `/ca.mobileconfig` — CA-only trust profile (no DNS override)
//!
//! The mobile API does NOT include the mutating routes (overrides, cache
//! flush, blocking toggle, service CRUD, etc.). Even if a user sets
//! `api_bind_addr` to `0.0.0.0` for the main API, those routes stay on
//! port 5380; the mobile API on port 8765 never serves them. This is the
//! primary security boundary: anything exposed to the LAN is read-only.
use std::net::Ipv4Addr;
use std::sync::Arc;
use axum::extract::State;
use axum::http::{header, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use log::info;
use crate::ctx::ServerCtx;
use crate::mobileconfig::{build_mobileconfig, ProfileMode};
/// Content-Disposition for the full CA + DNS profile download.
const FULL_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa.mobileconfig\"";
/// Content-Disposition for the CA-only profile download.
const CA_ONLY_PROFILE_DISPOSITION: &str = "attachment; filename=\"numa-ca.mobileconfig\"";
/// Build the axum router for the mobile API.
///
/// Shares handler functions with the main API where possible (`health`,
/// `serve_ca`) so the response shapes are identical across both ports.
pub fn router(ctx: Arc<ServerCtx>) -> Router {
Router::new()
.route("/health", get(crate::api::health))
.route("/ca.pem", get(crate::api::serve_ca))
.route("/mobileconfig", get(serve_full_mobileconfig))
.route("/ca.mobileconfig", get(serve_ca_only_mobileconfig))
.with_state(ctx)
}
/// Start the mobile API listener on `bind_addr:port`. Runs until the
/// caller cancels the spawned task. Logs the URL on successful bind.
pub async fn start(ctx: Arc<ServerCtx>, bind_addr: String, port: u16) -> crate::Result<()> {
let addr: std::net::SocketAddr = format!("{}:{}", bind_addr, port).parse()?;
let listener = tokio::net::TcpListener::bind(addr).await?;
info!("Mobile API listening on http://{}", addr);
let app = router(ctx);
axum::serve(listener, app).await?;
Ok(())
}
/// Serve the full mobileconfig profile (CA + DNS settings), with the
/// DNS payload pointing at the current LAN IP. Each request reads the
/// fresh LAN IP from `ctx.lan_ip` so the profile always reflects the
/// laptop's current network state.
async fn serve_full_mobileconfig(
State(ctx): State<Arc<ServerCtx>>,
) -> Result<impl IntoResponse, StatusCode> {
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
let lan_ip: Ipv4Addr = *ctx.lan_ip.lock().unwrap();
let profile = build_mobileconfig(ProfileMode::Full { lan_ip }, ca_pem);
Ok(profile_response(profile, FULL_PROFILE_DISPOSITION))
}
/// Serve the CA-only mobileconfig profile. Trusts the Numa local CA but
/// does NOT change the device's DNS settings. Used by the iOS companion
/// app's DoT mode, where the app configures DNS via `NEDNSSettingsManager`
/// and only needs the system trust store to accept Numa's self-signed cert.
async fn serve_ca_only_mobileconfig(
State(ctx): State<Arc<ServerCtx>>,
) -> Result<impl IntoResponse, StatusCode> {
let ca_pem = ctx.ca_pem.as_deref().ok_or(StatusCode::NOT_FOUND)?;
let profile = build_mobileconfig(ProfileMode::CaOnly, ca_pem);
Ok(profile_response(profile, CA_ONLY_PROFILE_DISPOSITION))
}
/// Shared response constructor for both mobileconfig variants.
/// Identical headers; only the Content-Disposition filename differs.
fn profile_response(profile: String, disposition: &'static str) -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/x-apple-aspen-config"),
(header::CONTENT_DISPOSITION, disposition),
(header::CACHE_CONTROL, "no-store"),
],
profile,
)
}

View File

@@ -1,294 +0,0 @@
//! Apple `.mobileconfig` profile generator.
//!
//! Builds iOS Configuration Profiles that Numa serves to phones for one-tap
//! CA trust and DNS-over-TLS setup. The plist structure is hand-rendered
//! via `format!` — no plist crate dependency, deterministic output, small
//! binary footprint.
//!
//! Two modes:
//!
//! - [`ProfileMode::Full`]: CA trust payload + DNS settings payload pointing
//! at a specific LAN IP over DoT. This is what `numa setup-phone` has
//! always produced — the user scans a QR, installs this profile, and the
//! phone is configured for DoT through Numa in a single step (after the
//! iOS Certificate Trust Settings toggle, which is a separate system
//! gate we can't bypass).
//!
//! - [`ProfileMode::CaOnly`]: CA trust payload only, no DNS settings. Used
//! by the future iOS companion app flow where `NEDNSSettingsManager`
//! configures DNS programmatically and we only need the system trust
//! store to accept Numa's DoT cert. Installing this profile does NOT
//! change the user's DNS at all.
//!
//! Payload identifiers and UUIDs are fixed (not randomized) so iOS replaces
//! the existing profile on re-install rather than accumulating duplicates.
//! The `Full` and `CaOnly` profiles have distinct top-level UUIDs so they
//! can coexist as separate installed profiles, but they share the same CA
//! payload UUID since the CA itself is the same trust anchor in both.
use std::net::Ipv4Addr;
/// Top-level UUID and PayloadIdentifier for the full profile (CA + DNS).
/// Changing this breaks in-place replacement on existing iOS installs.
const FULL_PROFILE_UUID: &str = "F1E2D3C4-B5A6-7890-1234-567890ABCDEF";
const FULL_PROFILE_ID: &str = "com.numa.dns.profile";
/// Top-level UUID and PayloadIdentifier for the CA-only profile.
/// Distinct from `FULL_PROFILE_UUID` so a user can install one, the other,
/// or both without the latest install silently replacing a different mode.
const CA_ONLY_PROFILE_UUID: &str = "F2E3D4C5-B6A7-8901-2345-67890ABCDEF0";
const CA_ONLY_PROFILE_ID: &str = "com.numa.dns.ca.profile";
/// CA trust payload UUID. Same in both modes — iOS will see "the same CA
/// trust anchor" regardless of which wrapping profile contains it.
const CA_PAYLOAD_UUID: &str = "B2C3D4E5-F6A7-8901-BCDE-F12345678901";
const CA_PAYLOAD_ID: &str = "com.numa.dns.ca";
/// DNS settings payload UUID (Full mode only).
const DNS_PAYLOAD_UUID: &str = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890";
const DNS_PAYLOAD_ID: &str = "com.numa.dns.dot";
/// Profile mode determines which payloads are included in the generated
/// `.mobileconfig`.
#[derive(Debug, Clone)]
pub enum ProfileMode {
/// Full profile: CA trust anchor + managed DNS settings payload
/// pointing at the given LAN IP over DoT. This is what the classic
/// `numa setup-phone` QR flow serves.
Full { lan_ip: Ipv4Addr },
/// CA-only profile: just the trust anchor, no DNS settings. For use
/// with the iOS companion app which manages DNS programmatically via
/// `NEDNSSettingsManager` and only needs the system trust store to
/// accept Numa's self-signed DoT cert.
CaOnly,
}
/// Build a full `.mobileconfig` profile as an XML plist string.
pub fn build_mobileconfig(mode: ProfileMode, ca_pem: &str) -> String {
let ca_payload = build_ca_payload(ca_pem);
match mode {
ProfileMode::Full { lan_ip } => {
let dns_payload = build_dns_payload(lan_ip);
let payloads = format!("{}\n{}", ca_payload, dns_payload);
let description = format!(
"Trusts the Numa local CA and routes DNS queries to Numa over DoT on your local network ({lan_ip})"
);
wrap_plist(
&payloads,
FULL_PROFILE_UUID,
FULL_PROFILE_ID,
&description,
"Numa DNS",
)
}
ProfileMode::CaOnly => wrap_plist(
&ca_payload,
CA_ONLY_PROFILE_UUID,
CA_ONLY_PROFILE_ID,
"Trusts the Numa local Certificate Authority. Does not change your DNS settings.",
"Numa CA",
),
}
}
/// Strip the PEM header/footer and newlines from a CA cert, leaving raw
/// base64 for embedding in a plist `<data>` block.
fn pem_to_base64(pem: &str) -> String {
pem.lines()
.filter(|line| !line.starts_with("-----"))
.collect::<String>()
}
/// Wrap the base64 CA cert at 52 chars per line for plist readability
/// (matches Apple convention in hand-written profiles).
fn chunk_base64(base64: &str) -> String {
base64
.chars()
.collect::<Vec<_>>()
.chunks(52)
.map(|chunk| format!("\t\t\t{}", chunk.iter().collect::<String>()))
.collect::<Vec<_>>()
.join("\n")
}
/// Render the `com.apple.security.root` payload dict containing the CA cert.
fn build_ca_payload(ca_pem: &str) -> String {
let ca_wrapped = chunk_base64(&pem_to_base64(ca_pem));
format!(
r#" <dict>
<key>PayloadCertificateFileName</key>
<string>numa-ca.pem</string>
<key>PayloadContent</key>
<data>
{ca}
</data>
<key>PayloadDescription</key>
<string>Numa local Certificate Authority — required for DoT trust</string>
<key>PayloadDisplayName</key>
<string>Numa Local CA</string>
<key>PayloadIdentifier</key>
<string>{ca_id}</string>
<key>PayloadType</key>
<string>com.apple.security.root</string>
<key>PayloadUUID</key>
<string>{ca_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>"#,
ca = ca_wrapped,
ca_id = CA_PAYLOAD_ID,
ca_uuid = CA_PAYLOAD_UUID,
)
}
/// 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 {
format!(
r#" <dict>
<key>DNSSettings</key>
<dict>
<key>DNSProtocol</key>
<string>TLS</string>
<key>ServerAddresses</key>
<array>
<string>{ip}</string>
</array>
<key>ServerName</key>
<string>numa.numa</string>
</dict>
<key>PayloadDescription</key>
<string>Routes all DNS queries through Numa over DNS-over-TLS</string>
<key>PayloadDisplayName</key>
<string>Numa DNS-over-TLS</string>
<key>PayloadIdentifier</key>
<string>{dns_id}</string>
<key>PayloadType</key>
<string>com.apple.dnsSettings.managed</string>
<key>PayloadUUID</key>
<string>{dns_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>"#,
ip = lan_ip,
dns_id = DNS_PAYLOAD_ID,
dns_uuid = DNS_PAYLOAD_UUID,
)
}
/// Wrap one or more payload dicts in the top-level plist structure
/// with Configuration type, PayloadContent array, and profile metadata.
fn wrap_plist(
payloads: &str,
top_uuid: &str,
top_id: &str,
description: &str,
display_name: &str,
) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
{payloads}
</array>
<key>PayloadDescription</key>
<string>{description}</string>
<key>PayloadDisplayName</key>
<string>{display_name}</string>
<key>PayloadIdentifier</key>
<string>{top_id}</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadUUID</key>
<string>{top_uuid}</string>
<key>PayloadVersion</key>
<integer>1</integer>
</dict>
</plist>
"#,
payloads = payloads,
description = description,
display_name = display_name,
top_id = top_id,
top_uuid = top_uuid,
)
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_PEM: &str =
"-----BEGIN CERTIFICATE-----\nMIIBkDCCATagAwIBAgIUTEST\n-----END CERTIFICATE-----\n";
#[test]
fn pem_to_base64_strips_headers() {
let pem = "-----BEGIN CERTIFICATE-----\nABCDEF\nGHIJKL\n-----END CERTIFICATE-----\n";
assert_eq!(pem_to_base64(pem), "ABCDEFGHIJKL");
}
#[test]
fn full_profile_contains_ip_and_ca() {
let config = build_mobileconfig(
ProfileMode::Full {
lan_ip: Ipv4Addr::new(192, 168, 1, 100),
},
SAMPLE_PEM,
);
assert!(config.contains("192.168.1.100"));
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
assert!(config.contains("com.apple.security.root"));
assert!(config.contains("com.apple.dnsSettings.managed"));
assert!(config.contains("DNSProtocol"));
assert!(config.contains(FULL_PROFILE_UUID));
assert!(config.contains(FULL_PROFILE_ID));
}
#[test]
fn ca_only_profile_contains_ca_but_not_dns() {
let config = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
assert!(config.contains("MIIBkDCCATagAwIBAgIUTEST"));
assert!(config.contains("com.apple.security.root"));
assert!(!config.contains("com.apple.dnsSettings.managed"));
assert!(!config.contains("DNSProtocol"));
assert!(!config.contains("ServerAddresses"));
assert!(config.contains(CA_ONLY_PROFILE_UUID));
assert!(config.contains(CA_ONLY_PROFILE_ID));
}
#[test]
fn full_and_ca_only_have_distinct_top_uuids() {
let full = build_mobileconfig(
ProfileMode::Full {
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
},
SAMPLE_PEM,
);
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
assert!(full.contains(FULL_PROFILE_UUID));
assert!(!full.contains(CA_ONLY_PROFILE_UUID));
assert!(ca_only.contains(CA_ONLY_PROFILE_UUID));
assert!(!ca_only.contains(FULL_PROFILE_UUID));
}
#[test]
fn both_modes_share_ca_payload_uuid() {
let full = build_mobileconfig(
ProfileMode::Full {
lan_ip: Ipv4Addr::new(10, 0, 0, 1),
},
SAMPLE_PEM,
);
let ca_only = build_mobileconfig(ProfileMode::CaOnly, SAMPLE_PEM);
assert!(full.contains(CA_PAYLOAD_UUID));
assert!(ca_only.contains(CA_PAYLOAD_UUID));
}
}

View File

@@ -117,22 +117,6 @@ impl OverrideStore {
self.entries.clear(); self.entries.clear();
} }
pub fn heap_bytes(&self) -> usize {
let per_slot = std::mem::size_of::<u64>()
+ std::mem::size_of::<String>()
+ std::mem::size_of::<OverrideEntry>()
+ 1;
let table = self.entries.capacity() * per_slot;
let heap: usize = self
.entries
.iter()
.map(|(k, v)| {
k.capacity() + v.domain.capacity() + v.target.capacity() + v.record.heap_bytes()
})
.sum();
table + heap
}
pub fn active_count(&self) -> usize { pub fn active_count(&self) -> usize {
self.entries.values().filter(|e| !e.is_expired()).count() self.entries.values().filter(|e| !e.is_expired()).count()
} }
@@ -170,16 +154,3 @@ fn parse_target(domain: &str, target: &str, ttl: u32) -> Result<(QueryType, DnsR
}, },
)) ))
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heap_bytes_grows_with_entries() {
let mut store = OverrideStore::new();
let empty = store.heap_bytes();
store.insert("example.com", "1.2.3.4", 300, None).unwrap();
assert!(store.heap_bytes() > empty);
}
}

View File

@@ -57,34 +57,6 @@ impl DnsPacket {
} }
} }
pub fn query(id: u16, domain: &str, qtype: crate::question::QueryType) -> DnsPacket {
let mut pkt = DnsPacket::new();
pkt.header.id = id;
pkt.header.recursion_desired = true;
pkt.questions
.push(crate::question::DnsQuestion::new(domain.to_string(), qtype));
pkt
}
pub fn heap_bytes(&self) -> usize {
fn records_heap(records: &[DnsRecord]) -> usize {
records
.iter()
.map(|r| std::mem::size_of::<DnsRecord>() + r.heap_bytes())
.sum::<usize>()
}
let questions: usize = self
.questions
.iter()
.map(|q| std::mem::size_of::<DnsQuestion>() + q.name.capacity())
.sum();
questions
+ records_heap(&self.answers)
+ records_heap(&self.authorities)
+ records_heap(&self.resources)
+ self.edns.as_ref().map_or(0, |e| e.options.capacity())
}
pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket { pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket {
let mut resp = DnsPacket::new(); let mut resp = DnsPacket::new();
resp.header.id = query.header.id; resp.header.id = query.header.id;
@@ -610,16 +582,4 @@ mod tests {
panic!("expected DNSKEY"); panic!("expected DNSKEY");
} }
} }
#[test]
fn heap_bytes_accounts_for_records() {
let mut pkt = DnsPacket::new();
let empty = pkt.heap_bytes();
pkt.answers.push(DnsRecord::A {
domain: "example.com".into(),
addr: "1.2.3.4".parse().unwrap(),
ttl: 300,
});
assert!(pkt.heap_bytes() > empty);
}
} }

View File

@@ -38,21 +38,6 @@ impl QueryLog {
self.entries.push_back(entry); self.entries.push_back(entry);
} }
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn heap_bytes(&self) -> usize {
self.entries
.iter()
.map(|e| std::mem::size_of::<QueryLogEntry>() + e.domain.capacity())
.sum()
}
pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> { pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> {
self.entries self.entries
.iter() .iter()
@@ -92,25 +77,3 @@ pub struct QueryLogFilter {
pub since: Option<SystemTime>, pub since: Option<SystemTime>,
pub limit: Option<usize>, pub limit: Option<usize>,
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heap_bytes_grows_with_entries() {
let mut log = QueryLog::new(100);
let empty = log.heap_bytes();
log.push(QueryLogEntry {
timestamp: SystemTime::now(),
src_addr: "127.0.0.1:1234".parse().unwrap(),
domain: "example.com".into(),
query_type: QueryType::A,
path: QueryPath::Forwarded,
rescode: ResultCode::NOERROR,
latency_us: 500,
dnssec: DnssecStatus::Indeterminate,
});
assert!(log.heap_bytes() > empty);
}
}

View File

@@ -136,46 +136,6 @@ impl DnsRecord {
} }
} }
pub fn heap_bytes(&self) -> usize {
match self {
DnsRecord::A { domain, .. } => domain.capacity(),
DnsRecord::NS { domain, host, .. } | DnsRecord::CNAME { domain, host, .. } => {
domain.capacity() + host.capacity()
}
DnsRecord::MX { domain, host, .. } => domain.capacity() + host.capacity(),
DnsRecord::AAAA { domain, .. } => domain.capacity(),
DnsRecord::DNSKEY {
domain, public_key, ..
} => domain.capacity() + public_key.capacity(),
DnsRecord::DS { domain, digest, .. } => domain.capacity() + digest.capacity(),
DnsRecord::RRSIG {
domain,
signer_name,
signature,
..
} => domain.capacity() + signer_name.capacity() + signature.capacity(),
DnsRecord::NSEC {
domain,
next_domain,
type_bitmap,
..
} => domain.capacity() + next_domain.capacity() + type_bitmap.capacity(),
DnsRecord::NSEC3 {
domain,
salt,
next_hashed_owner,
type_bitmap,
..
} => {
domain.capacity()
+ salt.capacity()
+ next_hashed_owner.capacity()
+ type_bitmap.capacity()
}
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
}
}
pub fn set_ttl(&mut self, new_ttl: u32) { pub fn set_ttl(&mut self, new_ttl: u32) {
match self { match self {
DnsRecord::A { ttl, .. } DnsRecord::A { ttl, .. }
@@ -690,14 +650,4 @@ mod tests {
let parsed = round_trip(&rec); let parsed = round_trip(&rec);
assert_eq!(rec, parsed); assert_eq!(rec, parsed);
} }
#[test]
fn heap_bytes_reflects_string_capacity() {
let rec = DnsRecord::CNAME {
domain: "a]".repeat(100),
host: "b".repeat(200),
ttl: 60,
};
assert!(rec.heap_bytes() >= 300);
}
} }

View File

@@ -9,7 +9,7 @@ use crate::cache::DnsCache;
use crate::forward::forward_udp; use crate::forward::forward_udp;
use crate::header::ResultCode; use crate::header::ResultCode;
use crate::packet::DnsPacket; use crate::packet::DnsPacket;
use crate::question::QueryType; use crate::question::{DnsQuestion, QueryType};
use crate::record::DnsRecord; use crate::record::DnsRecord;
use crate::srtt::SrttCache; use crate::srtt::SrttCache;
@@ -21,8 +21,7 @@ const UDP_FAIL_THRESHOLD: u8 = 3;
static QUERY_ID: AtomicU16 = AtomicU16::new(1); static QUERY_ID: AtomicU16 = AtomicU16::new(1);
static UDP_FAILURES: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); static UDP_FAILURES: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
pub(crate) static UDP_DISABLED: std::sync::atomic::AtomicBool = static UDP_DISABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
std::sync::atomic::AtomicBool::new(false);
fn next_id() -> u16 { fn next_id() -> u16 {
QUERY_ID.fetch_add(1, Ordering::Relaxed) QUERY_ID.fetch_add(1, Ordering::Relaxed)
@@ -32,14 +31,6 @@ fn dns_addr(ip: impl Into<IpAddr>) -> SocketAddr {
SocketAddr::new(ip.into(), 53) SocketAddr::new(ip.into(), 53)
} }
fn record_to_addr(rec: &DnsRecord) -> Option<SocketAddr> {
match rec {
DnsRecord::A { addr, .. } => Some(dns_addr(*addr)),
DnsRecord::AAAA { addr, .. } => Some(dns_addr(*addr)),
_ => None,
}
}
pub fn reset_udp_state() { pub fn reset_udp_state() {
UDP_DISABLED.store(false, Ordering::Release); UDP_DISABLED.store(false, Ordering::Release);
UDP_FAILURES.store(0, Ordering::Release); UDP_FAILURES.store(0, Ordering::Release);
@@ -54,8 +45,11 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
Some(h) => *h, Some(h) => *h,
None => return, None => return,
}; };
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS); let mut probe = DnsPacket::new();
probe.header.recursion_desired = false; probe.header.id = next_id();
probe
.questions
.push(DnsQuestion::new(".".to_string(), QueryType::NS));
if forward_udp(&probe, hint, Duration::from_millis(1500)) if forward_udp(&probe, hint, Duration::from_millis(1500))
.await .await
.is_ok() .is_ok()
@@ -65,21 +59,6 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
} }
} }
/// Probe whether recursive resolution works by querying root servers.
/// Tries up to 3 hints before declaring failure.
pub async fn probe_recursive(root_hints: &[SocketAddr]) -> bool {
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
probe.header.recursion_desired = false;
for hint in root_hints.iter().take(3) {
if let Ok(resp) = forward_udp(&probe, *hint, Duration::from_secs(3)).await {
if !resp.answers.is_empty() || !resp.authorities.is_empty() {
return true;
}
}
}
false
}
pub async fn prime_tld_cache( pub async fn prime_tld_cache(
cache: &RwLock<DnsCache>, cache: &RwLock<DnsCache>,
root_hints: &[SocketAddr], root_hints: &[SocketAddr],
@@ -316,8 +295,17 @@ pub(crate) fn resolve_iterative<'a>(
) )
.await .await
{ {
new_ns_addrs for rec in &ns_resp.answers {
.extend(ns_resp.answers.iter().filter_map(record_to_addr)); match rec {
DnsRecord::A { addr, .. } => {
new_ns_addrs.push(dns_addr(*addr));
}
DnsRecord::AAAA { addr, .. } => {
new_ns_addrs.push(dns_addr(*addr));
}
_ => {}
}
}
} }
if !new_ns_addrs.is_empty() { if !new_ns_addrs.is_empty() {
break; break;
@@ -371,7 +359,13 @@ fn find_closest_ns(
if let DnsRecord::NS { host, .. } = ns_rec { if let DnsRecord::NS { host, .. } = ns_rec {
for qt in [QueryType::A, QueryType::AAAA] { for qt in [QueryType::A, QueryType::AAAA] {
if let Some(resp) = guard.lookup(host, qt) { if let Some(resp) = guard.lookup(host, qt) {
addrs.extend(resp.answers.iter().filter_map(record_to_addr)); for rec in &resp.answers {
match rec {
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
_ => {}
}
}
} }
} }
} }
@@ -457,7 +451,13 @@ fn addrs_from_cache(cache: &RwLock<DnsCache>, name: &str) -> Vec<SocketAddr> {
let mut addrs = Vec::new(); let mut addrs = Vec::new();
for qt in [QueryType::A, QueryType::AAAA] { for qt in [QueryType::A, QueryType::AAAA] {
if let Some(pkt) = guard.lookup(name, qt) { if let Some(pkt) = guard.lookup(name, qt) {
addrs.extend(pkt.answers.iter().filter_map(record_to_addr)); for rec in &pkt.answers {
match rec {
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
_ => {}
}
}
} }
} }
addrs addrs
@@ -467,13 +467,15 @@ fn glue_addrs_for(response: &DnsPacket, ns_name: &str) -> Vec<SocketAddr> {
response response
.resources .resources
.iter() .iter()
.filter(|r| match r { .filter_map(|r| match r {
DnsRecord::A { domain, .. } | DnsRecord::AAAA { domain, .. } => { DnsRecord::A { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
domain.eq_ignore_ascii_case(ns_name) Some(dns_addr(*addr))
} }
_ => false, DnsRecord::AAAA { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
Some(dns_addr(*addr))
}
_ => None,
}) })
.filter_map(record_to_addr)
.collect() .collect()
} }
@@ -593,8 +595,12 @@ async fn send_query(
server: SocketAddr, server: SocketAddr,
srtt: &RwLock<SrttCache>, srtt: &RwLock<SrttCache>,
) -> crate::Result<DnsPacket> { ) -> crate::Result<DnsPacket> {
let mut query = DnsPacket::query(next_id(), qname, qtype); let mut query = DnsPacket::new();
query.header.id = next_id();
query.header.recursion_desired = false; query.header.recursion_desired = false;
query
.questions
.push(DnsQuestion::new(qname.to_string(), qtype));
query.edns = Some(crate::packet::EdnsOpt { query.edns = Some(crate::packet::EdnsOpt {
do_bit: true, do_bit: true,
..Default::default() ..Default::default()
@@ -870,25 +876,14 @@ mod tests {
}; };
let handler = handler.clone(); let handler = handler.clone();
tokio::spawn(async move { tokio::spawn(async move {
let timeout = std::time::Duration::from_secs(5);
// Read length-prefixed DNS query // Read length-prefixed DNS query
let mut len_buf = [0u8; 2]; let mut len_buf = [0u8; 2];
if tokio::time::timeout(timeout, stream.read_exact(&mut len_buf)) if stream.read_exact(&mut len_buf).await.is_err() {
.await
.ok()
.and_then(|r| r.ok())
.is_none()
{
return; return;
} }
let len = u16::from_be_bytes(len_buf) as usize; let len = u16::from_be_bytes(len_buf) as usize;
let mut data = vec![0u8; len]; let mut data = vec![0u8; len];
if tokio::time::timeout(timeout, stream.read_exact(&mut data)) if stream.read_exact(&mut data).await.is_err() {
.await
.ok()
.and_then(|r| r.ok())
.is_none()
{
return; return;
} }
@@ -1060,7 +1055,11 @@ mod tests {
}) })
.await; .await;
let query = DnsPacket::query(0xBEEF, "test.com", QueryType::A); let mut query = DnsPacket::new();
query.header.id = 0xBEEF;
query
.questions
.push(DnsQuestion::new("test.com".to_string(), QueryType::A));
let resp = crate::forward::forward_tcp(&query, server_addr, Duration::from_secs(2)) let resp = crate::forward::forward_tcp(&query, server_addr, Duration::from_secs(2))
.await .await
@@ -1120,7 +1119,11 @@ mod tests {
.unwrap(); .unwrap();
}); });
let query = DnsPacket::query(0xCAFE, "strict.test", QueryType::A); let mut query = DnsPacket::new();
query.header.id = 0xCAFE;
query
.questions
.push(DnsQuestion::new("strict.test".to_string(), QueryType::A));
let resp = crate::forward::forward_tcp(&query, addr, Duration::from_secs(2)) let resp = crate::forward::forward_tcp(&query, addr, Duration::from_secs(2))
.await .await

View File

@@ -1,126 +0,0 @@
//! `numa setup-phone` CLI — thin QR wrapper over the persistent mobile API.
//!
//! Before the mobile API existed, this command spawned its own one-shot
//! HTTP server on port 8765 to serve a freshly-generated mobileconfig
//! for a single download. That role now belongs to
//! [`crate::mobile_api`], which runs persistently alongside the main
//! API and serves `/mobileconfig` at the same port whenever Numa is
//! running.
//!
//! This command is now a thin terminal-side wrapper:
//!
//! 1. Detect the current LAN IP
//! 2. Render a terminal QR code pointing at
//! `http://<lan_ip>:8765/mobileconfig`
//! 3. Print install instructions and exit
//!
//! The user scans the QR, iOS fetches the profile from the mobile API
//! (which is always up as long as `numa` is running), installs, and the
//! user walks through Settings → Certificate Trust Settings to enable
//! trust.
//!
//! Numa must be running for the profile download to succeed; if the
//! mobile API is not listening on port 8765, the download will fail
//! and the user will see Safari's "Cannot Connect to Server" error.
//! The CLI prints a reminder about this at the bottom of the output.
use qrcode::render::unicode;
use qrcode::QrCode;
/// Default port where the persistent mobile API serves `/mobileconfig`.
/// Matches `MobileConfig::default().port` in `config.rs`. If the user
/// has overridden `[mobile] port = N` in `numa.toml`, they'll need to
/// adjust the URL manually — this CLI uses the default without parsing
/// `numa.toml`.
const SETUP_PORT: u16 = 8765;
fn render_qr(url: &str) -> Result<String, String> {
let code = QrCode::new(url).map_err(|e| format!("failed to encode QR: {}", e))?;
Ok(code
.render::<unicode::Dense1x2>()
.dark_color(unicode::Dense1x2::Light)
.light_color(unicode::Dense1x2::Dark)
.build())
}
/// Run the `numa setup-phone` flow.
pub async fn run() -> Result<(), String> {
let lan_ip = crate::lan::detect_lan_ip()
.ok_or("could not detect LAN IP — are you connected to a network?")?;
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], SETUP_PORT));
let api_reachable = tokio::time::timeout(
std::time::Duration::from_millis(500),
tokio::net::TcpStream::connect(addr),
)
.await
.map(|r| r.is_ok())
.unwrap_or(false);
if !api_reachable {
eprintln!();
eprintln!(
" \x1b[1;38;2;192;98;58mNuma\x1b[0m — mobile API is not reachable on port {}.",
SETUP_PORT
);
eprintln!();
eprintln!(" The phone won't be able to download the profile until the mobile");
eprintln!(" API is running. Add this to your numa.toml and restart Numa:");
eprintln!();
eprintln!(" [mobile]");
eprintln!(" enabled = true");
eprintln!();
return Err("mobile API not running".into());
}
let url = format!("http://{}:{}/mobileconfig", lan_ip, SETUP_PORT);
let qr = render_qr(&url)?;
eprintln!();
eprintln!(" \x1b[1;38;2;192;98;58mNuma Phone Setup\x1b[0m");
eprintln!();
eprintln!(" Profile URL: \x1b[36m{}\x1b[0m", url);
eprintln!();
for line in qr.lines() {
eprintln!(" {}", line);
}
eprintln!();
eprintln!(" \x1b[1mOn your iPhone:\x1b[0m");
eprintln!(" 1. Open Camera, point at the QR code, tap the yellow banner");
eprintln!(" 2. Allow the download when Safari asks");
eprintln!(" 3. Open Settings — tap \"Profile Downloaded\" near the top");
eprintln!(" (or: Settings → General → VPN & Device Management → Numa DNS)");
eprintln!(" 4. Tap Install (top right), enter passcode, Install again");
eprintln!(" 5. \x1b[1mSettings → General → About → Certificate Trust Settings\x1b[0m");
eprintln!(" Toggle ON \"Numa Local CA\" — required for DoT to work");
eprintln!();
eprintln!(
" \x1b[33mNote:\x1b[0m profile uses your laptop's current IP ({}). If your",
lan_ip
);
eprintln!(" laptop changes networks, re-scan this QR — iOS will replace the");
eprintln!(" existing profile automatically (fixed UUID).");
eprintln!();
eprintln!(
" \x1b[90mThe profile is served by Numa's persistent mobile API on port {}.\x1b[0m",
SETUP_PORT
);
eprintln!(" \x1b[90mMake sure `numa` is running before scanning. If it's not,\x1b[0m");
eprintln!(" \x1b[90mstart it with `sudo numa install` or run it interactively.\x1b[0m");
eprintln!();
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_qr_produces_unicode() {
let qr = render_qr("http://192.168.1.9:8765/mobileconfig").unwrap();
assert!(!qr.is_empty());
// Dense1x2 uses these block characters
assert!(qr.chars().any(|c| matches!(c, '█' | '▀' | '▄' | ' ')));
}
}

View File

@@ -47,19 +47,16 @@ impl SrttCache {
/// 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()) let age_secs = entry.updated_at.elapsed().as_secs();
}
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
if age_secs > DECAY_AFTER_SECS { if age_secs > DECAY_AFTER_SECS {
let periods = (age_secs / DECAY_AFTER_SECS).min(8); let periods = (age_secs / DECAY_AFTER_SECS).min(8);
let mut srtt = srtt_ms; let mut srtt = entry.srtt_ms;
for _ in 0..periods { for _ in 0..periods {
srtt = (srtt + INITIAL_SRTT_MS) / 2; srtt = (srtt + INITIAL_SRTT_MS) / 2;
} }
srtt srtt
} else { } else {
srtt_ms entry.srtt_ms
} }
} }
@@ -103,14 +100,6 @@ impl SrttCache {
addrs.sort_by_key(|a| self.get(a.ip())); addrs.sort_by_key(|a| self.get(a.ip()));
} }
pub fn heap_bytes(&self) -> usize {
let per_slot = std::mem::size_of::<u64>()
+ std::mem::size_of::<IpAddr>()
+ std::mem::size_of::<SrttEntry>()
+ 1;
self.entries.capacity() * per_slot
}
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.entries.len() self.entries.len()
} }
@@ -214,86 +203,6 @@ mod tests {
assert_eq!(addrs, original); assert_eq!(addrs, original);
} }
#[test]
fn no_decay_within_threshold() {
// At exactly DECAY_AFTER_SECS, no decay applied
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS);
assert_eq!(result, FAILURE_PENALTY_MS);
}
#[test]
fn one_decay_period() {
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1);
let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2;
assert_eq!(result, expected);
}
#[test]
fn multiple_decay_periods() {
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1);
let mut expected = FAILURE_PENALTY_MS;
for _ in 0..4 {
expected = (expected + INITIAL_SRTT_MS) / 2;
}
assert_eq!(result, expected);
}
#[test]
fn decay_caps_at_8_periods() {
// 9 periods and 100 periods should produce the same result (capped at 8)
let a = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 9 + 1);
let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
assert_eq!(a, b);
}
#[test]
fn decay_converges_toward_initial() {
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
assert!(
diff < 25,
"expected near INITIAL_SRTT_MS, got {} (diff={})",
decayed,
diff
);
}
#[test]
fn record_rtt_applies_decay_before_ewma() {
// Verify decay is applied before EWMA in record_rtt by checking
// that a saturated penalty + long age + new sample produces a low SRTT
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8);
// EWMA: (decayed * 7 + 50) / 8
let after_ewma = (decayed * 7 + 50) / 8;
assert!(
after_ewma < 500,
"expected decay before EWMA, got srtt={}",
after_ewma
);
}
#[test]
fn decay_reranks_stale_failures() {
// After enough decay, a failed server (5000ms) converges toward
// INITIAL (200ms), which is below a stable server at 300ms
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
assert!(
decayed < 300,
"expected decayed penalty ({}) < 300ms",
decayed
);
}
#[test]
fn heap_bytes_grows_with_entries() {
let mut cache = SrttCache::new(true);
let empty = cache.heap_bytes();
for i in 1..=10u8 {
cache.record_rtt(ip(i), 100, false);
}
assert!(cache.heap_bytes() > empty);
}
#[test] #[test]
fn eviction_removes_oldest() { fn eviction_removes_oldest() {
let mut cache = SrttCache::new(true); let mut cache = SrttCache::new(true);

View File

@@ -1,97 +1,9 @@
use std::time::Instant; use std::time::Instant;
/// Returns the process memory footprint in bytes, or 0 if unavailable.
/// macOS: phys_footprint (matches Activity Monitor). Linux: RSS from /proc/self/statm.
pub fn process_memory_bytes() -> usize {
#[cfg(target_os = "macos")]
{
macos_rss()
}
#[cfg(target_os = "linux")]
{
linux_rss()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
0
}
}
#[cfg(target_os = "macos")]
fn macos_rss() -> usize {
use std::mem;
extern "C" {
fn mach_task_self() -> u32;
fn task_info(
target_task: u32,
flavor: u32,
task_info_out: *mut TaskVmInfo,
task_info_count: *mut u32,
) -> i32;
}
// Partial task_vm_info_data_t — only fields up to phys_footprint.
#[repr(C)]
struct TaskVmInfo {
virtual_size: u64,
region_count: i32,
page_size: i32,
resident_size: u64,
resident_size_peak: u64,
device: u64,
device_peak: u64,
internal: u64,
internal_peak: u64,
external: u64,
external_peak: u64,
reusable: u64,
reusable_peak: u64,
purgeable_volatile_pmap: u64,
purgeable_volatile_resident: u64,
purgeable_volatile_virtual: u64,
compressed: u64,
compressed_peak: u64,
compressed_lifetime: u64,
phys_footprint: u64,
}
const TASK_VM_INFO: u32 = 22;
let mut info: TaskVmInfo = unsafe { mem::zeroed() };
let mut count = (mem::size_of::<TaskVmInfo>() / mem::size_of::<u32>()) as u32;
let kr = unsafe { task_info(mach_task_self(), TASK_VM_INFO, &mut info, &mut count) };
if kr == 0 {
info.phys_footprint as usize
} else {
0
}
}
#[cfg(target_os = "linux")]
fn linux_rss() -> usize {
extern "C" {
fn sysconf(name: i32) -> i64;
}
const SC_PAGESIZE: i32 = 30; // x86_64 + aarch64; differs on mips (28), sparc (29)
let page_size = unsafe { sysconf(SC_PAGESIZE) };
let page_size = if page_size > 0 {
page_size as usize
} else {
4096
};
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
if let Some(rss_pages) = statm.split_whitespace().nth(1) {
if let Ok(pages) = rss_pages.parse::<usize>() {
return pages * page_size;
}
}
}
0
}
pub struct ServerStats { pub struct ServerStats {
queries_total: u64, queries_total: u64,
queries_forwarded: u64, queries_forwarded: u64,
queries_recursive: u64, queries_recursive: u64,
queries_coalesced: u64,
queries_cached: u64, queries_cached: u64,
queries_blocked: u64, queries_blocked: u64,
queries_local: u64, queries_local: u64,
@@ -100,13 +12,12 @@ pub struct ServerStats {
started_at: Instant, started_at: Instant,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum QueryPath { pub enum QueryPath {
Local, Local,
Cached, Cached,
Forwarded, Forwarded,
Recursive, Recursive,
Coalesced,
Blocked, Blocked,
Overridden, Overridden,
UpstreamError, UpstreamError,
@@ -119,7 +30,6 @@ impl QueryPath {
QueryPath::Cached => "CACHED", QueryPath::Cached => "CACHED",
QueryPath::Forwarded => "FORWARD", QueryPath::Forwarded => "FORWARD",
QueryPath::Recursive => "RECURSIVE", QueryPath::Recursive => "RECURSIVE",
QueryPath::Coalesced => "COALESCED",
QueryPath::Blocked => "BLOCKED", QueryPath::Blocked => "BLOCKED",
QueryPath::Overridden => "OVERRIDE", QueryPath::Overridden => "OVERRIDE",
QueryPath::UpstreamError => "SERVFAIL", QueryPath::UpstreamError => "SERVFAIL",
@@ -135,8 +45,6 @@ impl QueryPath {
Some(QueryPath::Forwarded) Some(QueryPath::Forwarded)
} else if s.eq_ignore_ascii_case("RECURSIVE") { } else if s.eq_ignore_ascii_case("RECURSIVE") {
Some(QueryPath::Recursive) Some(QueryPath::Recursive)
} else if s.eq_ignore_ascii_case("COALESCED") {
Some(QueryPath::Coalesced)
} else if s.eq_ignore_ascii_case("BLOCKED") { } else if s.eq_ignore_ascii_case("BLOCKED") {
Some(QueryPath::Blocked) Some(QueryPath::Blocked)
} else if s.eq_ignore_ascii_case("OVERRIDE") { } else if s.eq_ignore_ascii_case("OVERRIDE") {
@@ -161,7 +69,6 @@ impl ServerStats {
queries_total: 0, queries_total: 0,
queries_forwarded: 0, queries_forwarded: 0,
queries_recursive: 0, queries_recursive: 0,
queries_coalesced: 0,
queries_cached: 0, queries_cached: 0,
queries_blocked: 0, queries_blocked: 0,
queries_local: 0, queries_local: 0,
@@ -178,7 +85,6 @@ impl ServerStats {
QueryPath::Cached => self.queries_cached += 1, QueryPath::Cached => self.queries_cached += 1,
QueryPath::Forwarded => self.queries_forwarded += 1, QueryPath::Forwarded => self.queries_forwarded += 1,
QueryPath::Recursive => self.queries_recursive += 1, QueryPath::Recursive => self.queries_recursive += 1,
QueryPath::Coalesced => self.queries_coalesced += 1,
QueryPath::Blocked => self.queries_blocked += 1, QueryPath::Blocked => self.queries_blocked += 1,
QueryPath::Overridden => self.queries_overridden += 1, QueryPath::Overridden => self.queries_overridden += 1,
QueryPath::UpstreamError => self.upstream_errors += 1, QueryPath::UpstreamError => self.upstream_errors += 1,
@@ -200,7 +106,6 @@ impl ServerStats {
total: self.queries_total, total: self.queries_total,
forwarded: self.queries_forwarded, forwarded: self.queries_forwarded,
recursive: self.queries_recursive, recursive: self.queries_recursive,
coalesced: self.queries_coalesced,
cached: self.queries_cached, cached: self.queries_cached,
local: self.queries_local, local: self.queries_local,
overridden: self.queries_overridden, overridden: self.queries_overridden,
@@ -216,12 +121,11 @@ impl ServerStats {
let secs = uptime.as_secs() % 60; let secs = uptime.as_secs() % 60;
log::info!( log::info!(
"STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}", "STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | cached {} | local {} | override {} | blocked {} | errors {}",
hours, mins, secs, hours, mins, secs,
self.queries_total, self.queries_total,
self.queries_forwarded, self.queries_forwarded,
self.queries_recursive, self.queries_recursive,
self.queries_coalesced,
self.queries_cached, self.queries_cached,
self.queries_local, self.queries_local,
self.queries_overridden, self.queries_overridden,
@@ -236,7 +140,6 @@ pub struct StatsSnapshot {
pub total: u64, pub total: u64,
pub forwarded: u64, pub forwarded: u64,
pub recursive: u64, pub recursive: u64,
pub coalesced: u64,
pub cached: u64, pub cached: u64,
pub local: u64, pub local: u64,
pub overridden: u64, pub overridden: u64,

File diff suppressed because it is too large Load Diff

View File

@@ -5,9 +5,7 @@ use std::sync::Arc;
use log::{info, warn}; use log::{info, warn};
use crate::ctx::ServerCtx; use crate::ctx::ServerCtx;
use rcgen::{ use rcgen::{BasicConstraints, CertificateParams, DnType, IsCa, KeyPair, KeyUsagePurpose, SanType};
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::ServerConfig; use rustls::ServerConfig;
use time::{Duration, OffsetDateTime}; use time::{Duration, OffsetDateTime};
@@ -15,13 +13,6 @@ use time::{Duration, OffsetDateTime};
const CA_VALIDITY_DAYS: i64 = 3650; // 10 years const CA_VALIDITY_DAYS: i64 = 3650; // 10 years
const CERT_VALIDITY_DAYS: i64 = 365; // 1 year const CERT_VALIDITY_DAYS: i64 = 365; // 1 year
/// Common Name on Numa's local CA. Referenced by trust-store helpers
/// (`security`, `certutil`) when locating the cert for removal.
pub const CA_COMMON_NAME: &str = "Numa Local CA";
/// Filename of the CA certificate inside the data dir.
pub const CA_FILE_NAME: &str = "ca.pem";
/// Collect all service + LAN peer names and regenerate the TLS cert. /// Collect all service + LAN peer names and regenerate the TLS cert.
pub fn regenerate_tls(ctx: &ServerCtx) { pub fn regenerate_tls(ctx: &ServerCtx) {
let tls = match &ctx.tls_config { let tls = match &ctx.tls_config {
@@ -33,7 +24,7 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
names.extend(ctx.lan_peers.lock().unwrap().names()); names.extend(ctx.lan_peers.lock().unwrap().names());
let names: Vec<String> = names.into_iter().collect(); let names: Vec<String> = names.into_iter().collect();
match build_tls_config(&ctx.proxy_tld, &names, Vec::new(), &ctx.data_dir) { match build_tls_config(&ctx.proxy_tld, &names) {
Ok(new_config) => { Ok(new_config) => {
tls.store(new_config); tls.store(new_config);
info!("TLS cert regenerated for {} services", names.len()); info!("TLS cert regenerated for {} services", names.len());
@@ -42,63 +33,20 @@ pub fn regenerate_tls(ctx: &ServerCtx) {
} }
} }
/// Advisory for TLS-setup failures caused by a non-writable data dir;
/// `None` if not applicable so the caller can fall back to the raw error.
pub fn try_data_dir_advisory(err: &crate::Error, data_dir: &Path) -> Option<String> {
let io_err = err.downcast_ref::<std::io::Error>()?;
if io_err.kind() != std::io::ErrorKind::PermissionDenied {
return None;
}
let o = "\x1b[1;38;2;192;98;58m";
let r = "\x1b[0m";
Some(format!(
"
{o}Numa{r} — HTTPS proxy disabled: cannot write TLS CA to {}.
The data directory is not writable by the current user. Numa needs
to persist a local Certificate Authority there to serve .numa over
HTTPS. DNS resolution and plain-HTTP proxy continue to work.
Fix — pick one:
1. Install Numa as the system resolver (sets up a writable data dir):
sudo numa install (on Windows, run as Administrator)
2. Point data_dir at a path you can write.
Create ~/.config/numa/numa.toml with:
[server]
data_dir = \"/path/you/can/write\"
",
data_dir.display()
))
}
/// Build a TLS config with a cert covering all provided service names. /// Build a TLS config with a cert covering all provided service names.
/// Wildcards under single-label TLDs (*.numa) are rejected by browsers, /// Wildcards under single-label TLDs (*.numa) are rejected by browsers,
/// so we list each service explicitly as a SAN. /// so we list each service explicitly as a SAN.
/// `alpn` is advertised in the TLS ServerHello — pass empty for the proxy pub fn build_tls_config(tld: &str, service_names: &[String]) -> crate::Result<Arc<ServerConfig>> {
/// (which accepts any ALPN), or `[b"dot"]` for DoT (RFC 7858 §3.2). let dir = crate::data_dir();
/// `data_dir` is where the CA material is stored — taken from let (ca_cert, ca_key) = ensure_ca(&dir)?;
/// `[server] data_dir` in numa.toml (defaults to `crate::data_dir()`). let (cert_chain, key) = generate_service_cert(&ca_cert, &ca_key, tld, service_names)?;
pub fn build_tls_config(
tld: &str,
service_names: &[String],
alpn: Vec<Vec<u8>>,
data_dir: &Path,
) -> crate::Result<Arc<ServerConfig>> {
let (ca_der, issuer) = ensure_ca(data_dir)?;
let (cert_chain, key) = generate_service_cert(&ca_der, &issuer, tld, service_names)?;
// Ensure a crypto provider is installed (rustls needs one) // Ensure a crypto provider is installed (rustls needs one)
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
let mut config = ServerConfig::builder() let config = ServerConfig::builder()
.with_no_client_auth() .with_no_client_auth()
.with_single_cert(cert_chain, key)?; .with_single_cert(cert_chain, key)?;
config.alpn_protocols = alpn;
info!( info!(
"TLS configured for {} .{} domains", "TLS configured for {} .{} domains",
@@ -108,20 +56,18 @@ pub fn build_tls_config(
Ok(Arc::new(config)) Ok(Arc::new(config))
} }
fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'static, KeyPair>)> { fn ensure_ca(dir: &Path) -> crate::Result<(rcgen::Certificate, KeyPair)> {
let ca_key_path = dir.join("ca.key"); let ca_key_path = dir.join("ca.key");
let ca_cert_path = dir.join(CA_FILE_NAME); let ca_cert_path = dir.join("ca.pem");
if ca_key_path.exists() && ca_cert_path.exists() { if ca_key_path.exists() && ca_cert_path.exists() {
let key_pem = std::fs::read_to_string(&ca_key_path)?; let key_pem = std::fs::read_to_string(&ca_key_path)?;
let cert_pem = std::fs::read_to_string(&ca_cert_path)?; let cert_pem = std::fs::read_to_string(&ca_cert_path)?;
let key_pair = KeyPair::from_pem(&key_pem)?; let key_pair = KeyPair::from_pem(&key_pem)?;
let ca_der = rustls_pemfile::certs(&mut cert_pem.as_bytes()) let params = CertificateParams::from_ca_cert_pem(&cert_pem)?;
.next() let cert = params.self_signed(&key_pair)?;
.ok_or("empty CA PEM file")??;
let issuer = Issuer::from_ca_cert_der(&ca_der, key_pair)?;
info!("loaded CA from {:?}", ca_cert_path); info!("loaded CA from {:?}", ca_cert_path);
return Ok((ca_der, issuer)); return Ok((cert, key_pair));
} }
// Generate new CA // Generate new CA
@@ -131,7 +77,7 @@ fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'stat
let mut params = CertificateParams::default(); let mut params = CertificateParams::default();
params params
.distinguished_name .distinguished_name
.push(DnType::CommonName, CA_COMMON_NAME); .push(DnType::CommonName, "Numa Local CA");
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.not_before = OffsetDateTime::now_utc(); params.not_before = OffsetDateTime::now_utc();
@@ -149,16 +95,14 @@ fn ensure_ca(dir: &Path) -> crate::Result<(CertificateDer<'static>, Issuer<'stat
} }
info!("generated CA at {:?}", ca_cert_path); info!("generated CA at {:?}", ca_cert_path);
let ca_der = cert.der().clone(); Ok((cert, key_pair))
let issuer = Issuer::new(params, key_pair);
Ok((ca_der, issuer))
} }
/// Generate a cert with explicit SANs for each service name. /// Generate a cert with explicit SANs for each service name.
/// Always regenerated at startup (~5ms) — no disk caching needed. /// Always regenerated at startup (~5ms) — no disk caching needed.
fn generate_service_cert( fn generate_service_cert(
ca_der: &CertificateDer<'static>, ca_cert: &rcgen::Certificate,
issuer: &Issuer<'_, KeyPair>, ca_key: &KeyPair,
tld: &str, tld: &str,
service_names: &[String], service_names: &[String],
) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> { ) -> crate::Result<(Vec<CertificateDer<'static>>, PrivateKeyDer<'static>)> {
@@ -193,7 +137,7 @@ fn generate_service_cert(
params.not_before = OffsetDateTime::now_utc(); params.not_before = OffsetDateTime::now_utc();
params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS); params.not_after = OffsetDateTime::now_utc() + Duration::days(CERT_VALIDITY_DAYS);
let cert = params.signed_by(&key_pair, issuer)?; let cert = params.signed_by(&key_pair, ca_cert, ca_key)?;
info!( info!(
"generated TLS cert for: {}", "generated TLS cert for: {}",
@@ -204,39 +148,9 @@ fn generate_service_cert(
.join(", ") .join(", ")
); );
let cert_der = cert.der().clone(); let cert_der = CertificateDer::from(cert.der().to_vec());
let ca_cert_der = ca_der.clone(); let ca_der = CertificateDer::from(ca_cert.der().to_vec());
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der())); let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_pair.serialize_der()));
Ok((vec![cert_der, ca_cert_der], key_der)) Ok((vec![cert_der, ca_der], key_der))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn try_data_dir_advisory_permission_denied() {
let err: crate::Error =
Box::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied));
let path = PathBuf::from("/usr/local/var/numa");
let msg = try_data_dir_advisory(&err, &path).expect("should advise");
assert!(msg.contains("HTTPS proxy disabled"));
assert!(msg.contains("/usr/local/var/numa"));
assert!(msg.contains("numa install"));
assert!(msg.contains("data_dir"));
}
#[test]
fn try_data_dir_advisory_skips_other_io_kinds() {
let err: crate::Error = Box::new(std::io::Error::from(std::io::ErrorKind::NotFound));
assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none());
}
#[test]
fn try_data_dir_advisory_skips_non_io_errors() {
let err: crate::Error = "rcgen failure".into();
assert!(try_data_dir_advisory(&err, &PathBuf::from("/x")).is_none());
}
} }

View File

@@ -1,123 +0,0 @@
#!/usr/bin/env bash
#
# Cross-distro CA trust contract test for issue #35.
#
# Runs the exact shell commands `src/system_dns.rs::trust_ca_linux` would run
# on each Linux trust-store family (Debian, Fedora pki, Arch p11-kit), and
# asserts the certificate ends up in (and is removed from) the system bundle.
#
# This is a contract test, not an integration test: it doesn't drive the Rust
# code (that would need systemd-in-container). It verifies the assumptions in
# `LINUX_TRUST_STORES` against the real distro behavior. If you change that
# table in src/system_dns.rs, update the per-distro cases below to match.
#
# Requirements: docker, openssl (host).
# Usage: ./tests/docker/install-trust.sh
set -euo pipefail
cd "$(dirname "$0")/../.."
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
# Self-signed CA fixture, mounted into each container as ca.pem.
# basicConstraints=CA:TRUE is required — without it, Debian's
# update-ca-certificates silently skips the cert during bundle build.
FIXTURE_DIR=$(mktemp -d)
trap 'rm -rf "$FIXTURE_DIR"' EXIT
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$FIXTURE_DIR/ca.key" \
-out "$FIXTURE_DIR/ca.pem" \
-subj "/CN=Numa Local CA Test $(date +%s)" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
# Distro bundles store certs differently — Debian writes raw PEM only,
# Fedora prepends "# CN" comment headers, Arch via extract-compat is
# raw PEM. To detect cert presence uniformly we grep for a deterministic
# substring of the base64 body (first base64 line is unique per cert).
CERT_TAG=$(sed -n '2p' "$FIXTURE_DIR/ca.pem")
PASSED=0; FAILED=0
run_case() {
local distro="$1"; shift
local image="$1"; shift
local platform="$1"; shift
local script="$1"
printf "── %s (%s) ──\n" "$distro" "$image"
if docker run --rm \
--platform "$platform" \
--security-opt seccomp=unconfined \
-e CERT_TAG="$CERT_TAG" \
-e DEBIAN_FRONTEND=noninteractive \
-v "$FIXTURE_DIR/ca.pem:/fixture/ca.pem:ro" \
"$image" bash -c "$script"; then
printf "${GREEN}${RESET} %s\n\n" "$distro"
PASSED=$((PASSED + 1))
else
printf "${RED}${RESET} %s\n\n" "$distro"
FAILED=$((FAILED + 1))
fi
}
# Debian / Ubuntu / Mint — anchor: /usr/local/share/ca-certificates/*.crt
run_case "debian" "debian:stable" "linux/amd64" '
set -e
apt-get update -qq
apt-get install -qq -y ca-certificates >/dev/null
install -m 0644 /fixture/ca.pem /usr/local/share/ca-certificates/numa-local-ca.crt
update-ca-certificates >/dev/null 2>&1
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
echo " install: cert present in bundle"
rm /usr/local/share/ca-certificates/numa-local-ca.crt
update-ca-certificates --fresh >/dev/null 2>&1
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
echo " uninstall: cert STILL present (regression)" >&2
exit 1
fi
echo " uninstall: cert removed from bundle"
'
# Fedora / RHEL / CentOS / SUSE — anchor: /etc/pki/ca-trust/source/anchors/*.pem
run_case "fedora" "fedora:latest" "linux/amd64" '
set -e
dnf install -q -y ca-certificates >/dev/null
install -m 0644 /fixture/ca.pem /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
update-ca-trust extract
grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem
echo " install: cert present in bundle"
rm /etc/pki/ca-trust/source/anchors/numa-local-ca.pem
update-ca-trust extract
if grep -q "$CERT_TAG" /etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem; then
echo " uninstall: cert STILL present (regression)" >&2
exit 1
fi
echo " uninstall: cert removed from bundle"
'
# Arch / Manjaro — anchor: /etc/ca-certificates/trust-source/anchors/*.pem
# archlinux:latest is x86_64-only; --platform forces emulation on Apple Silicon.
run_case "arch" "archlinux:latest" "linux/amd64" '
set -e
# pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu emulation.
sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf
pacman -Sy --noconfirm --needed ca-certificates p11-kit >/dev/null 2>&1
install -m 0644 /fixture/ca.pem /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
trust extract-compat
grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt
echo " install: cert present in bundle"
rm /etc/ca-certificates/trust-source/anchors/numa-local-ca.pem
trust extract-compat
if grep -q "$CERT_TAG" /etc/ssl/certs/ca-certificates.crt; then
echo " uninstall: cert STILL present (regression)" >&2
exit 1
fi
echo " uninstall: cert removed from bundle"
'
printf "── summary ──\n"
printf " ${GREEN}passed${RESET}: %d\n" "$PASSED"
printf " ${RED}failed${RESET}: %d\n" "$FAILED"
[ "$FAILED" -eq 0 ]

View File

@@ -1,147 +0,0 @@
#!/usr/bin/env bash
#
# Arch Linux compatibility smoke test.
#
# Builds numa from source inside an archlinux:latest container, runs it
# in forward mode on port 5354, and verifies a single DNS query returns
# an A record. Validates the "Arch compatible" claim end-to-end before
# release announcements.
#
# Dogfooding: the test numa forwards to the host's running numa via
# host.docker.internal (Docker Desktop's host gateway). This avoids the
# Docker NAT/UDP issues with public resolvers and exercises the realistic
# numa-on-numa shape. Requires the host to be running numa on port 53.
#
# First run is slow (~8-12 min): image pull + pacman + cold cargo build.
# No caching across runs.
#
# Requirements: docker, host running numa on 0.0.0.0:53
# Usage: ./tests/docker/smoke-arch.sh
set -euo pipefail
cd "$(dirname "$0")/../.."
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
# Precondition: the test numa-on-arch forwards to the host numa as its
# upstream (dogfood pattern). Fail fast with a clear error if there is
# no working DNS on the host, rather than letting the dig inside the
# container time out with "deadline has elapsed".
if ! dig @127.0.0.1 google.com A +short +time=1 +tries=1 >/dev/null 2>&1; then
printf "${RED}error:${RESET} host numa is not answering on 127.0.0.1:53\n" >&2
echo " This test forwards to the host numa via host.docker.internal." >&2
echo " Start numa on the host first (sudo numa install), then rerun." >&2
exit 1
fi
echo "── building + running numa on archlinux:latest ──"
echo " (first run is slow: image pull + pacman + cold cargo build, ~8-12 min)"
echo
docker run --rm \
--platform linux/amd64 \
--security-opt seccomp=unconfined \
-v "$PWD:/src:ro" \
-v numa-arch-cargo:/root/.cargo \
-v numa-arch-target:/work/target \
archlinux:latest bash -c '
set -e
# pacman 7+ filters syscalls in its own sandbox; disable for Rosetta/qemu
sed -i "s/^#DisableSandboxSyscalls/DisableSandboxSyscalls/" /etc/pacman.conf
echo "── pacman: installing build + runtime deps ──"
pacman -Sy --noconfirm --needed rust gcc pkgconf cmake make perl bind 2>&1 | tail -3
echo
# Copy source to a writable workdir, skipping target/ + .git so we
# do not pull in the host (macOS) build artifacts.
mkdir -p /work
tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf -
cd /work
echo "── cargo build --release --locked ──"
cargo build --release --locked 2>&1 | tail -5
echo
# Dogfood: forward to the host numa via host.docker.internal.
# numa parses upstream.address as a literal SocketAddr, so we resolve
# the hostname to an IPv4 address first (force v4 — getent hosts may
# return IPv6 first, and IPv6 addresses need bracketed addr:port form).
HOST_IP=$(getent ahostsv4 host.docker.internal | awk "/STREAM/ {print \$1; exit}")
if [ -z "$HOST_IP" ]; then
echo " ✗ could not resolve host.docker.internal to IPv4 (not on Docker Desktop?)"
exit 1
fi
echo "── starting numa on :5354 (forward to host numa at $HOST_IP:53) ──"
# Intentionally NOT setting [server] data_dir — we want to exercise the
# default code path (data_dir() → daemon_data_dir() → /var/lib/numa) so
# the FHS-path assertion below verifies the live wiring, not just the
# unit-tested helper.
cat > /tmp/numa.toml <<EOF
[server]
bind_addr = "127.0.0.1:5354"
api_port = 5381
[upstream]
mode = "forward"
address = "$HOST_IP"
port = 53
EOF
./target/release/numa /tmp/numa.toml > /tmp/numa.log 2>&1 &
NUMA_PID=$!
# Poll for readiness — numa is ready when it answers a query
READY=0
for i in 1 2 3 4 5 6 7 8; do
sleep 1
if dig @127.0.0.1 -p 5354 google.com A +short +time=1 +tries=1 2>/dev/null \
| grep -qE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$"; then
READY=1
break
fi
done
if [ "$READY" -ne 1 ]; then
echo " ✗ numa did not return an A record after 8s"
echo " numa log:"
cat /tmp/numa.log
kill $NUMA_PID 2>/dev/null || true
exit 1
fi
echo "── dig @127.0.0.1 -p 5354 google.com A ──"
ANSWER=$(dig @127.0.0.1 -p 5354 google.com A +short +time=2 +tries=1)
echo "$ANSWER" | sed "s/^/ /"
kill $NUMA_PID 2>/dev/null || true
# FHS path assertion: the default data dir on Linux must be /var/lib/numa
# (not the legacy /usr/local/var/numa). The CA cert generated at startup
# is the canonical proof that numa wrote to the right place.
echo
echo "── FHS path check ──"
if [ -f /var/lib/numa/ca.pem ]; then
echo " ✓ CA cert at /var/lib/numa/ca.pem (FHS path)"
else
echo " ✗ CA cert NOT at /var/lib/numa/ca.pem"
echo " ls /var/lib/numa/:"
ls -la /var/lib/numa/ 2>&1 | sed "s/^/ /"
echo " ls /usr/local/var/numa/:"
ls -la /usr/local/var/numa/ 2>&1 | sed "s/^/ /"
exit 1
fi
if [ -e /usr/local/var/numa ]; then
echo " ✗ legacy path /usr/local/var/numa unexpectedly exists on a fresh container"
exit 1
fi
echo " ✓ legacy path /usr/local/var/numa absent (fresh install used FHS)"
echo
echo " ✓ numa built, ran, answered a forward query, and used the FHS data dir on Arch"
'
echo
printf "${GREEN}── smoke-arch passed ──${RESET}\n"

View File

@@ -1,138 +0,0 @@
#!/usr/bin/env bash
#
# Port-53 conflict advisory integration test.
#
# Builds numa from source inside a debian:bookworm container, pre-binds
# port 53 with a UDP socket, then runs numa bare (default bind_addr
# 0.0.0.0:53). Verifies:
# - process exits with code 1
# - stderr contains the advisory ("cannot bind to")
# - stderr contains both fix suggestions ("numa install", "bind_addr")
#
# This is the end-to-end test for the fix in:
# src/main.rs — AddrInUse match arm → eprint advisory + process::exit(1)
#
# No systemd-resolved needed — the conflict is simulated by a Python
# UDP socket held open before numa starts.
#
# Requirements: docker
# Usage: ./tests/docker/smoke-port53.sh
set -euo pipefail
cd "$(dirname "$0")/../.."
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
pass() { printf " ${GREEN}${RESET} %s\n" "$1"; }
fail() { printf " ${RED}${RESET} %s\n" "$1"; printf " %s\n" "$2"; FAILED=$((FAILED+1)); }
FAILED=0
echo "── smoke-port53: building + testing numa on debian:bookworm ──"
echo " (first run is slow: image pull + cold cargo build, ~5-8 min)"
echo
OUTPUT=$(docker run --rm \
--platform linux/amd64 \
-v "$PWD:/src:ro" \
-v numa-port53-cargo:/root/.cargo \
-v numa-port53-target:/work/target \
debian:bookworm bash -c '
set -e
apt-get update -qq && apt-get install -y -qq curl build-essential python3 2>&1 | tail -3
# Install rustup if not already in the cargo cache volume
if ! command -v cargo &>/dev/null; then
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet
fi
. "$HOME/.cargo/env"
# Copy source to a writable workdir
mkdir -p /work
tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf -
cd /work
echo "── cargo build --release --locked ──"
cargo build --release --locked 2>&1 | tail -5
echo
# Write the holder script to a file to avoid quoting hell.
# Holds port 53 until killed — no sleep race.
cat > /tmp/hold53.py << '"'"'PYEOF'"'"'
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()
PYEOF
python3 /tmp/hold53.py &
HOLDER_PID=$!
# Verify the holder is actually up before proceeding
sleep 0.3
if ! kill -0 $HOLDER_PID 2>/dev/null; then
echo "holder_failed=1"
exit 1
fi
echo "── running numa with port 53 already bound ──"
# timeout 5: guards against numa not exiting (advisory not fired, bug present)
# Capture stderr to a file so the exit code is not clobbered by || or $()
set +e
timeout 5 ./target/release/numa > /tmp/numa-stderr.txt 2>&1
EXIT_CODE=$?
set -e
STDERR=$(cat /tmp/numa-stderr.txt)
kill $HOLDER_PID 2>/dev/null || true
echo "exit_code=$EXIT_CODE"
printf "%s" "$STDERR" | sed "s/^/ numa: /"
' 2>&1)
echo "$OUTPUT"
echo
echo "── assertions ──"
if echo "$OUTPUT" | grep -q "holder_failed=1"; then
echo " SETUP FAILED: could not pre-bind port 53 inside container"
exit 1
fi
EXIT_CODE=$(echo "$OUTPUT" | grep '^exit_code=' | cut -d= -f2)
if [ "${EXIT_CODE:-}" = "1" ]; then
pass "exits with code 1"
else
fail "exits with code 1" "got: exit_code=${EXIT_CODE:-<missing>}"
fi
if echo "$OUTPUT" | grep -q "cannot bind to"; then
pass "advisory printed to stderr"
else
fail "advisory printed to stderr" "stderr did not contain 'cannot bind to'"
fi
if echo "$OUTPUT" | grep -q "numa install"; then
pass "advisory offers 'sudo numa install'"
else
fail "advisory offers 'sudo numa install'" "not found in output"
fi
if echo "$OUTPUT" | grep -q "bind_addr"; then
pass "advisory offers non-privileged port alternative"
else
fail "advisory offers non-privileged port alternative" "'bind_addr' not found in output"
fi
echo
if [ "$FAILED" -eq 0 ]; then
printf "${GREEN}── smoke-port53 passed ──${RESET}\n"
exit 0
else
printf "${RED}── smoke-port53 failed ($FAILED assertion(s)) ──${RESET}\n"
exit 1
fi

View File

@@ -404,241 +404,6 @@ check "Cache flushed" \
kill "$NUMA_PID" 2>/dev/null || true kill "$NUMA_PID" 2>/dev/null || true
wait "$NUMA_PID" 2>/dev/null || true wait "$NUMA_PID" 2>/dev/null || true
sleep 1
# ---- Suite 5: DNS-over-TLS (RFC 7858) ----
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Suite 5: DNS-over-TLS (RFC 7858) ║"
echo "╚══════════════════════════════════════════╝"
if ! command -v kdig >/dev/null 2>&1; then
printf " ${DIM}skipped — install 'knot' for kdig${RESET}\n"
elif ! command -v openssl >/dev/null 2>&1; then
printf " ${DIM}skipped — openssl not found${RESET}\n"
else
DOT_PORT=8853
DOT_CERT=/tmp/numa-integration-dot.crt
DOT_KEY=/tmp/numa-integration-dot.key
# Generate a test cert mirroring production self_signed_tls SAN shape
# (*.numa wildcard + explicit numa.numa apex).
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$DOT_KEY" -out "$DOT_CERT" \
-subj "/CN=Numa .numa services" \
-addext "subjectAltName=DNS:*.numa,DNS:numa.numa" \
>/dev/null 2>&1
# Suite 5 uses a local zone so it's upstream-independent — the point is
# to exercise the DoT transport layer (handshake, ALPN, framing,
# persistent connections), not re-test recursive resolution.
cat > "$CONFIG" << CONF
[server]
bind_addr = "127.0.0.1:$PORT"
api_port = $API_PORT
[upstream]
mode = "forward"
address = "127.0.0.1"
port = 65535
[cache]
max_entries = 10000
[blocking]
enabled = false
[proxy]
enabled = false
[dot]
enabled = true
port = $DOT_PORT
bind_addr = "127.0.0.1"
cert_path = "$DOT_CERT"
key_path = "$DOT_KEY"
[[zones]]
domain = "dot-test.example"
record_type = "A"
value = "10.0.0.1"
ttl = 60
CONF
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
NUMA_PID=$!
sleep 4
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
FAILED=$((FAILED + 1))
printf " ${RED}${RESET} DoT startup\n"
printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")"
else
echo ""
echo "=== Listener ==="
check "DoT bound on 127.0.0.1:$DOT_PORT" \
"DoT listening on 127.0.0.1:$DOT_PORT" \
"$(grep 'DoT listening' "$LOG")"
KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$DOT_CERT +tls-hostname=numa.numa +time=5 +retry=0"
echo ""
echo "=== Queries over DoT ==="
check "DoT local zone A record" \
"10.0.0.1" \
"$($KDIG +short dot-test.example A 2>/dev/null)"
# +keepopen reuses one TLS connection for multiple queries — tests
# persistent connection handling. kdig applies options left-to-right,
# so +short and +keepopen must come before the query specs.
check "DoT persistent connection (3 queries, 1 handshake)" \
"10.0.0.1" \
"$($KDIG +keepopen +short dot-test.example A dot-test.example A dot-test.example A 2>/dev/null | head -1)"
echo ""
echo "=== ALPN ==="
# Positive case: client offers "dot", server picks it.
ALPN_OK=$(echo "" | openssl s_client -connect "127.0.0.1:$DOT_PORT" \
-servername numa.numa -alpn dot -CAfile "$DOT_CERT" 2>&1 </dev/null || true)
check "DoT negotiates ALPN \"dot\"" \
"ALPN protocol: dot" \
"$ALPN_OK"
# Negative case: client offers only "h2", server must reject the
# handshake with no_application_protocol alert (cross-protocol
# confusion defense, RFC 7858bis §3.2).
if echo "" | openssl s_client -connect "127.0.0.1:$DOT_PORT" \
-servername numa.numa -alpn h2 -CAfile "$DOT_CERT" \
</dev/null >/dev/null 2>&1; then
ALPN_MISMATCH="handshake unexpectedly succeeded"
else
ALPN_MISMATCH="rejected"
fi
check "DoT rejects non-dot ALPN" \
"rejected" \
"$ALPN_MISMATCH"
fi
kill "$NUMA_PID" 2>/dev/null || true
wait "$NUMA_PID" 2>/dev/null || true
rm -f "$DOT_CERT" "$DOT_KEY"
fi
sleep 1
# ---- Suite 6: Proxy + DoT coexistence ----
echo ""
echo "╔══════════════════════════════════════════╗"
echo "║ Suite 6: Proxy + DoT Coexistence ║"
echo "╚══════════════════════════════════════════╝"
if ! command -v kdig >/dev/null 2>&1 || ! command -v openssl >/dev/null 2>&1; then
printf " ${DIM}skipped — needs kdig + openssl${RESET}\n"
else
DOT_PORT=8853
PROXY_HTTP_PORT=8080
PROXY_HTTPS_PORT=8443
NUMA_DATA=/tmp/numa-integration-data
# Fresh data dir so we generate a fresh CA for this suite. Path is set
# via [server] data_dir in the TOML below, not an env var — numa treats
# its config file as the single source of truth for all knobs.
rm -rf "$NUMA_DATA"
mkdir -p "$NUMA_DATA"
cat > "$CONFIG" << CONF
[server]
bind_addr = "127.0.0.1:$PORT"
api_port = $API_PORT
data_dir = "$NUMA_DATA"
[upstream]
mode = "forward"
address = "127.0.0.1"
port = 65535
[cache]
max_entries = 10000
[blocking]
enabled = false
[proxy]
enabled = true
port = $PROXY_HTTP_PORT
tls_port = $PROXY_HTTPS_PORT
tld = "numa"
bind_addr = "127.0.0.1"
[dot]
enabled = true
port = $DOT_PORT
bind_addr = "127.0.0.1"
[[zones]]
domain = "dot-test.example"
record_type = "A"
value = "10.0.0.1"
ttl = 60
CONF
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
NUMA_PID=$!
sleep 4
if ! kill -0 "$NUMA_PID" 2>/dev/null; then
FAILED=$((FAILED + 1))
printf " ${RED}${RESET} Startup with proxy + DoT\n"
printf " ${DIM}%s${RESET}\n" "$(tail -5 "$LOG")"
else
echo ""
echo "=== Both listeners ==="
check "DoT listener bound" \
"DoT listening on 127.0.0.1:$DOT_PORT" \
"$(grep 'DoT listening' "$LOG")"
check "HTTPS proxy listener bound" \
"HTTPS proxy listening on 127.0.0.1:$PROXY_HTTPS_PORT" \
"$(grep 'HTTPS proxy listening' "$LOG")"
PANIC_COUNT=$(grep -c 'panicked' "$LOG" 2>/dev/null || echo 0)
check "No startup panics in log" \
"^0$" \
"$PANIC_COUNT"
echo ""
echo "=== DoT works with proxy enabled ==="
# Proxy's build_tls_config runs first and creates the CA in
# $NUMA_DATA_DIR. DoT self_signed_tls then loads the same CA and
# issues its own leaf cert. One CA trusts both listeners.
CA="$NUMA_DATA/ca.pem"
KDIG="kdig @127.0.0.1 -p $DOT_PORT +tls +tls-ca=$CA +tls-hostname=numa.numa +time=5 +retry=0"
check "DoT local zone A (with proxy on)" \
"10.0.0.1" \
"$($KDIG +short dot-test.example A 2>/dev/null)"
echo ""
echo "=== Proxy TLS works with DoT enabled ==="
# Proxy cert has SAN numa.numa (auto-added "numa" service). A
# successful handshake validates that the proxy's separate
# ServerConfig wasn't disturbed by DoT's own cert generation.
PROXY_TLS=$(echo "" | openssl s_client -connect "127.0.0.1:$PROXY_HTTPS_PORT" \
-servername numa.numa -CAfile "$CA" 2>&1 </dev/null || true)
check "Proxy HTTPS TLS handshake succeeds" \
"Verify return code: 0 (ok)" \
"$PROXY_TLS"
fi
kill "$NUMA_PID" 2>/dev/null || true
wait "$NUMA_PID" 2>/dev/null || true
rm -rf "$NUMA_DATA"
fi
# Summary # Summary
echo "" echo ""

View File

@@ -1,94 +0,0 @@
#!/usr/bin/env bash
#
# Manual macOS CA trust contract test.
#
# Mirrors src/system_dns.rs::trust_ca_macos / untrust_ca_macos by running
# the same `security` shell commands against a fixture cert with a unique
# CN. Safe to run alongside a production numa install:
#
# - Test cert CN = "Numa Local CA Test <pid-ts>", always strictly longer
# than the production CN "Numa Local CA". `security find-certificate -c`
# does substring matching, so the test's search for $TEST_CN can never
# match the production cert (the search term is longer than the prod CN).
# - All deletes use `delete-certificate -Z <hash>`, which only touches the
# cert with that exact hash. Production and test certs have different
# hashes by construction (different key material), so the delete cannot
# reach the production cert even if a CN search somehow returned both.
#
# Mutates the System keychain (briefly). Cleans up on success or interrupt.
# Requires sudo for `security add-trusted-cert` and `delete-certificate`.
#
# Usage: ./tests/manual/install-trust-macos.sh
set -euo pipefail
if [[ "$OSTYPE" != darwin* ]]; then
echo "This test is macOS-only." >&2
exit 1
fi
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
# Production constant from src/tls.rs::CA_COMMON_NAME — keep in sync.
PROD_CN="Numa Local CA"
KEYCHAIN="/Library/Keychains/System.keychain"
# Notice if production numa is already installed. We proceed regardless —
# see header for why coexistence is safe (unique CN + by-hash deletion).
if security find-certificate -c "$PROD_CN" "$KEYCHAIN" >/dev/null 2>&1; then
echo " note: production '$PROD_CN' detected — proceeding alongside (test cert can't touch it)"
echo
fi
# Unique CN ensures the test cert can never collide with production.
TEST_CN="Numa Local CA Test $$-$(date +%s)"
FIXTURE_DIR=$(mktemp -d)
cleanup() {
# Best-effort: remove any test certs by hash if still present.
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
echo " cleanup: removing leftover test cert"
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
| awk '/^SHA-1 hash:/ {print $NF}' \
| while read -r hash; do
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null 2>&1 || true
done
fi
rm -rf "$FIXTURE_DIR"
}
trap cleanup EXIT
echo "── generating fixture CA ──"
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-keyout "$FIXTURE_DIR/ca.key" \
-out "$FIXTURE_DIR/ca.pem" \
-subj "/CN=$TEST_CN" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign" >/dev/null 2>&1
echo " CN: $TEST_CN"
echo
echo "── trust step (mirrors trust_ca_macos) ──"
sudo security add-trusted-cert -d -r trustRoot -k "$KEYCHAIN" "$FIXTURE_DIR/ca.pem"
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
printf " ${GREEN}${RESET} test cert found in keychain\n"
else
printf " ${RED}${RESET} test cert NOT found after add-trusted-cert\n"
exit 1
fi
echo
echo "── untrust step (mirrors untrust_ca_macos) ──"
security find-certificate -c "$TEST_CN" -a -Z "$KEYCHAIN" 2>/dev/null \
| awk '/^SHA-1 hash:/ {print $NF}' \
| while read -r hash; do
sudo security delete-certificate -Z "$hash" "$KEYCHAIN" >/dev/null
done
if security find-certificate -c "$TEST_CN" "$KEYCHAIN" >/dev/null 2>&1; then
printf " ${RED}${RESET} test cert STILL present after delete (regression)\n"
exit 1
fi
printf " ${GREEN}${RESET} test cert removed from keychain\n"
echo
printf "${GREEN}all checks passed${RESET}\n"