78 Commits

Author SHA1 Message Date
Razvan Dimescu
78711f516e chore: bump version to 0.8.0
Breaking: default mode changed from auto to forward.
New: memory footprint stats + dashboard panel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:11:34 +03:00
Razvan Dimescu
64d85ce770 feat: add memory footprint to /stats and dashboard (#26)
* feat: add memory footprint to /stats and dashboard

Per-structure heap estimation (cache, blocklist, query log, SRTT,
overrides) with process RSS via mach_task_basic_info / sysconf.
Dashboard gets a 6th stat card and a sidebar breakdown panel with
stacked bar visualization.

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

* fix: use phys_footprint on macOS to match Activity Monitor

Switch from MACH_TASK_BASIC_INFO (resident_size) to TASK_VM_INFO
(phys_footprint) which matches Activity Monitor's Memory column.
Also: capacity-aware heap estimation, entry counts in memory payload,
heap_bytes tests for all stores.

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

* refactor: remove redundant fields and fix naming in memory stats

Remove duplicate entry counts from MemoryStats (already in parent
StatsResponse), rename process_rss_bytes to process_memory_bytes
to match macOS phys_footprint semantics, drop restating comments.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:09:44 +03:00
Razvan Dimescu
8791198d10 feat: forward-by-default, auto recursive mode, Linux install fixes (#27)
* feat: auto recursive mode, fix Linux install

Auto mode (new default): probes a root server on startup; uses
recursive resolution if outbound DNS works, falls back to Quad9 DoH
if blocked. Dashboard shows mode indicator (green/yellow).

Linux install fixes:
- Add DNSStubListener=no to resolved drop-in (frees port 53)
- Configure DNS before starting service (correct ordering)
- Skip 127.0.0.53 in upstream detection
- `numa install` now does everything (service + DNS + CA)
- `numa uninstall` mirrors install (stop service + restore DNS)
- Extract is_loopback_or_stub() for consistent filtering

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

* feat: enable DNSSEC validation by default

With recursive as the default mode, DNSSEC validation completes the
trustless resolution chain. Strict mode remains off by default.

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

* feat: forward search domains to VPC resolver on Linux

Parse search/domain lines from resolv.conf and create conditional
forwarding rules to the original nameserver or AWS VPC resolver
(169.254.169.253). Fixes internal hostname resolution on cloud VMs
where recursive mode can't resolve private DNS zones.

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

* refactor: single-pass resolv.conf parsing, eliminate redundancies

Parse resolv.conf once for both upstream and search domains instead
of 2-3 reads. Extract CLOUD_VPC_RESOLVER constant. Use &'static str
for mode in StatsResponse. Remove dead read_upstream_from_file.

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

* fix: macOS install health check, harden recursive probe

Verify numa is listening (API port) before redirecting system DNS on
macOS — if the service fails to start (e.g. port 53 in use), unload
the service and abort instead of breaking DNS. Probe up to 3 root
hints before declaring recursive mode unavailable. Validate IPs from
resolvectl to avoid IPv6 fragment extraction. Extract DEFAULT_API_PORT
constant.

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

* fix: widen make_rule cfg gate to include Linux

make_rule was gated to macOS-only but discover_linux() calls it for
search domain forwarding rules. CI failed on Linux with E0425.

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

* feat: forward mode as default, recursive opt-in

Forward mode (transparent proxy to system DNS) is now the default.
Recursive and auto modes are explicit opt-in via config. This avoids
bypassing corporate DNS policies, captive portals, VPC private zones,
and parental controls on first install.

- Move #[default] from Auto to Forward on UpstreamMode
- DNSSEC defaults to off (no-op in forward mode)
- 3-way match in main: Forward/Recursive/Auto with clean separation
- Post-install message suggests mode = "recursive" for sovereignty
- Update README, site, and launch drafts messaging

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-01 08:49:16 +03:00
Razvan Dimescu
f9b503ab96 fix: include recursive and coalesced queries in cache hit rate denominator (#24)
The cache hit rate was computed as cached/(cached+forwarded+local+overridden),
excluding recursive and coalesced queries from the denominator. This inflated
the displayed rate (e.g. 57.9%) far above the actual cache proportion (20.9%).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 00:17:40 +03:00
Razvan Dimescu
2b99b39bcc chore: updated install methods 2026-03-29 23:33:45 +03:00
Razvan Dimescu
7ab97f4cdc chore: bump version to 0.7.3 2026-03-29 23:16:46 +03:00
Razvan Dimescu
65dcd9a9c5 feat: resolve .numa services to LAN IP for remote clients (#23)
* feat: resolve .numa services to LAN IP for remote clients

Remote DNS clients (e.g. phones on same WiFi) received 127.0.0.1 for
local .numa services, which is unreachable from their perspective.
Now returns the host's LAN IP when the query originates from a
non-loopback address. Also auto-widens proxy bind to 0.0.0.0 when
DNS is already public, and adds a startup warning when the proxy
remains localhost-only.

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

* fix: respect proxy bind_addr config, don't auto-widen

The auto-widen silently overrode an explicit config value — the user's
config should be the source of truth. Now the proxy always uses the
configured bind_addr, and the warning fires whenever it's 127.0.0.1.

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

* docs: update proxy bind_addr comment in example config

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-29 23:15:42 +03:00
Razvan Dimescu
32cd8624b4 refactor: deduplicate query builders, record extraction, sinkhole records (#22)
- Add DnsPacket::query(id, domain, qtype) constructor; replace mock_query,
  make_query, and 4 inline constructions across ctx/forward/recursive/api
- Add record_to_addr() in recursive.rs; replace 4 identical A/AAAA match
  blocks with filter_map one-liners
- Add sinkhole_record() in ctx.rs; consolidate localhost and blocklist
  A/AAAA branching into single calls
- Remove now-unused DnsQuestion imports

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 14:22:07 +03:00
Razvan Dimescu
bea0affdde chore: bump version to 0.7.2 2026-03-29 11:44:10 +03:00
Razvan Dimescu
bad4f25d7d docs: streamline README for clarity and scannability
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 11:42:08 +03:00
Razvan Dimescu
5f45e23f55 refactor: extract resolve_coalesced, test real code (#21)
* refactor: extract resolve_coalesced, rewrite tests against real code

Extract Disposition enum, acquire_inflight(), and resolve_coalesced()
from handle_query so coalescing logic is independently testable. Rewrite
integration tests to call resolve_coalesced directly with mock futures
instead of fighting the iterative resolver's NS chain. All 12 coalescing
tests now exercise production code paths, not tokio primitives.

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

* fix: SERVFAIL echoes question section, preserve error messages

resolve_coalesced now takes &DnsPacket instead of query_id so SERVFAIL
responses use response_from (echoing question section per RFC). Error
messages preserved via Option<String> return for upstream error logging.

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 11:14:25 +03:00
Razvan Dimescu
882508297e chore: bump version to 0.7.1 2026-03-29 10:39:17 +03:00
Razvan Dimescu
2b241c5755 blog: add DNSSEC chain-of-trust SVG diagram
Replace text-based chain trace with a visual diagram showing the
verification flow from cloudflare.com through .com TLD to root
trust anchor. Matches site color palette and typography.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:38:47 +03:00
Razvan Dimescu
7510c8e068 feat: in-flight query coalescing with COALESCED path (#20)
* feat: in-flight query coalescing for recursive resolver

When multiple queries for the same (domain, qtype) arrive concurrently
and all miss the cache, only the first triggers recursive resolution.
Subsequent queries wait on a broadcast channel for the result.

Prevents thundering herd where N concurrent cache misses each
independently walk the full NS chain, compounding timeouts.

Uses InflightGuard (Drop impl) to guarantee map cleanup on
panic/cancellation — prevents permanent SERVFAIL poisoning.

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

* style: add InflightMap type alias for clippy

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

* feat: add COALESCED query path and coalescing tests

Followers in the inflight coalescing path now log as COALESCED instead
of RECURSIVE, making it visible in the dashboard when queries were
deduplicated vs independently resolved. Adds 10 tests covering
InflightGuard cleanup, broadcast mechanics, and concurrent handle_query
coalescing through a mock TCP DNS server.

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

* style: cargo fmt

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

* refactor: extract acquire_inflight, rewrite tests against real code

Move Disposition enum and inflight acquisition logic into a standalone
acquire_inflight() function. Rewrite 4 tests that were exercising tokio
primitives to call the real coalescing code path instead.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 10:36:02 +03:00
Razvan Dimescu
87c321f3d4 chore: add release script and make target
Usage: make release VERSION=0.8.0
Bumps Cargo.toml + Cargo.lock, commits, tags, pushes — triggers
the existing GitHub Actions release workflow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 08:33:58 +03:00
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
9 changed files with 700 additions and 520 deletions

View File

@@ -37,10 +37,3 @@ jobs:
run: cargo build
- name: clippy
run: cargo clippy -- -D warnings
- name: test
run: cargo test
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: numa-windows-x86_64
path: target/debug/numa.exe

2
Cargo.lock generated
View File

@@ -1143,7 +1143,7 @@ dependencies = [
[[package]]
name = "numa"
version = "0.9.1"
version = "0.8.0"
dependencies = [
"arc-swap",
"axum",

View File

@@ -1,6 +1,6 @@
[package]
name = "numa"
version = "0.9.1"
version = "0.8.0"
authors = ["razvandimescu <razvan@dimescu.com>"]
edition = "2021"
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"

View File

@@ -15,32 +15,16 @@ Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by
## Quick Start
```bash
# macOS
brew install razvandimescu/tap/numa
# or: cargo install numa
# or: curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
# Linux
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
# Windows — download from GitHub Releases
# All platforms
cargo install numa
```
```bash
sudo numa # run in foreground (port 53 requires root/admin)
sudo numa # port 53 requires root
```
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
Set as system DNS:
| Platform | Install | Uninstall |
|----------|---------|-----------|
| macOS | `sudo numa install` | `sudo numa uninstall` |
| Linux | `sudo numa install` | `sudo numa uninstall` |
| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot |
On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry.
Set as system DNS: `sudo numa install`
## Local Services
@@ -59,13 +43,7 @@ Add path-based routing (`app.numa/api → :5001`), share services across machine
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)
By default, Numa forwards to your existing system DNS — everything works as before, just with caching and ad blocking on top. For full privacy, set `mode = "recursive"` — Numa resolves directly from root nameservers. No upstream dependency, no single entity sees your full query pattern. 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)
## LAN Discovery
@@ -96,7 +74,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
| Ad blocking | Yes | Yes | — | 385K+ domains |
| Web admin UI | Full | Full | — | Dashboard |
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary |
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
## Performance

View File

@@ -0,0 +1,618 @@
# Launch Drafts
## Lessons Learned
**r/selfhosted** (0 upvotes, hostile) — "replaces Pi-hole" framing triggered
defensive comparisons. Audience protects their stack.
**r/programare** (26 upvotes, 22 comments, 12K views, 90.6% ratio) — worked
because it led with technical achievement. But: "what does this offer over
/etc/hosts?" and "mature solutions exist (dnsmasq, nginx)" were the top
objections. Tool-replacement angle falls flat with generalist audiences.
**r/webdev** — removed by moderators (self-promotion rules).
Key takeaways:
- Lead with what's *unique*, not what it *replaces*
- Write like explaining to a colleague, not marketing copy
- Pick ONE hook per community — don't try to be everything
- Triple-check the GitHub link works before posting
- Authentic tone > polished bullets
- Agree with "just use X" — then show what X can't do
- Don't oversell the pkarr/token vision — one sentence max
- Benchmark request from r/programare (Mydocalm) — warm follow-up content
---
## Launch Order
~~0. **r/programare** — done (2026-03-21). 12K views, 26 upvotes, 22 comments.~~
~~1. **r/webdev** — removed by moderators.~~
~~2. **r/degoogle** — done~~
~~3. **r/node** — done~~
4. **r/coolgithubprojects** — zero friction, just post the repo
~~5. **r/sideproject** — done (2026-03-29)~~
6. **r/dns** — technical DNS audience, recursive + DNSSEC angle
7. **Show HN** — Tuesday-Thursday, 9-10 AM ET
8. **r/rust** — same day as HN, technical deep-dive
9. **r/commandline** — 24h after HN
10. **r/selfhosted** — only if HN hits front page, lead with recursive + LAN discovery
11. **r/programare follow-up** — benchmark post + recursive/DNSSEC update
---
## Community Drafts
### Show HN
**Title (72 chars):**
Show HN: I built a DNS resolver from scratch in Rust no DNS libraries
**Body:**
I wanted to understand how DNS actually works at the wire level, so I built
a resolver from scratch. No dns libraries — the RFC 1035 protocol (headers,
labels, compression pointers, record types) is all hand-parsed. It started
as a learning project and turned into something I use daily as my system DNS.
What it does today:
- **Forward mode by default** — transparent proxy to your existing DNS with
caching and ad blocking. Changes nothing about your network.
- **Full recursive resolver** — set `mode = "recursive"` and it resolves from
root nameservers. No upstream dependency. CNAME chasing, TLD priming, SRTT.
- **DNSSEC validation** — chain-of-trust verification from root KSK.
RSA/SHA-256, ECDSA P-256, Ed25519. Sets the AD bit on verified responses.
- **Ad blocking** — ~385K+ domains via Hagezi Pro, works on any network
- **DNS-over-HTTPS** — encrypted upstream (Quad9, Cloudflare, or any
provider) as an alternative to recursive mode
- **`.numa` local domains** — register `frontend.numa → localhost:5173` and
it creates both the DNS record and an HTTP/HTTPS reverse proxy with
auto-generated TLS certs. WebSocket passthrough works (Vite HMR).
- **LAN service discovery** — run Numa on two machines, they find each other
via UDP multicast. Zero config.
- **Developer overrides** — point any hostname to any IP, auto-reverts
after N minutes. REST API for scripting.
Single binary, macOS + Linux. `sudo numa install` and it's your system DNS —
forward mode by default, recursive when you're ready.
The interesting technical bits: the recursive resolver walks root → TLD →
authoritative with iterative queries, caching NS/DS/DNSKEY records at each
hop. DNSSEC validation verifies RRSIG signatures against DNSKEY, walks the
chain via DS records up to the hardcoded root trust anchor. ECDSA P-256
verification takes 174ns (benchmarked with criterion). Cold-cache validation
for a new domain is ~90ms, with only 1 network fetch needed (TLD chain is
pre-warmed on startup). SRTT-based nameserver selection learns which
servers respond fastest — average recursive query drops from 2.8s to
237ms after warmup (12x).
It also handles hostile networks: if your ISP blocks UDP port 53,
Numa detects this after 3 failures and switches all
queries to TCP automatically. Resets when you change networks. RFC 7816
query minimization means root servers only see the TLD, not your full
query.
The DNS cache adjusts TTLs on read (remaining time, not original). Each
query is an async tokio task. EDNS0 with DO bit and 1232-byte payload
(DNS Flag Day 2020).
Longer term I want to add pkarr/DHT resolution for self-sovereign DNS,
but that's future work.
https://github.com/razvandimescu/numa
---
### r/rust
**Title:** I built a recursive DNS resolver from scratch in Rust — DNSSEC, no DNS libraries
**Body:**
I've been building a DNS resolver in Rust as a learning project that became
my daily driver. The entire DNS wire protocol is implemented by hand —
no `trust-dns`, no `hickory-dns`, no `simple-dns`. Headers, label sequences,
compression pointers, EDNS, all of it.
Some things I found interesting while building this:
**Recursive resolution** — iterative queries from root hints, walking
root → TLD → authoritative. CNAME chasing, A+AAAA glue extraction from
additional sections, referral depth limits. TLD priming pre-warms NS + DS +
DNSKEY for 34 gTLDs + EU ccTLDs on startup.
**DNSSEC chain-of-trust** — the most involved part. Verify RRSIG signatures
against DNSKEY, walk DS records up to the hardcoded root KSK (key tag 20326).
Uses `ring` for crypto: RSA/SHA-256, ECDSA P-256 (174ns per verify), Ed25519.
RFC 3110 RSA keys need converting to PKCS#1 DER for ring — wrote an ASN.1
encoder for that. RRSIG time validity checks per RFC 4035 §5.3.1.
**NSEC/NSEC3 denial proofs** — proving a name *doesn't* exist is harder than
proving it does. NSEC uses canonical DNS name ordering to prove gap coverage.
NSEC3 uses iterated SHA-1 hashing + base32hex + a 3-part closest encloser
proof (RFC 5155 §8.4). Both require authority-section RRSIG verification.
**Wire protocol parsing** — DNS uses a binary format with label compression
(pointers back into the packet via 2-byte offsets). Parsing this correctly
is surprisingly tricky because pointers can chain. I use a `BytePacketBuffer`
that tracks position and handles jumps.
**Performance** — TLD chain pre-warming means cold-cache DNSSEC validation
needs ~1 DNSKEY fetch (down from 5). Referral DS piggybacking caches DS
from authority sections during resolution. ECDSA P-256 verify: 174ns.
RSA/SHA-256: 10.9µs. DS verify: 257ns.
**LAN service discovery** — Numa instances on the same network find each
other via UDP multicast. The tricky part was self-filtering: I initially
filtered by IP, but two instances on the same host share an IP. Switched to
a per-process instance ID (`pid ^ nanos`).
**Auto TLS** — generates a local CA + per-service certs using `rcgen`.
`numa install` trusts the CA in the OS keychain. HTTPS proxy via `rustls` +
`tokio-rustls`.
Single binary, no runtime dependencies. Uses `tokio`, `axum` (REST
API/dashboard), `hyper` (reverse proxy), `ring` (DNSSEC crypto), `reqwest`
(DoH), `socket2` (multicast), `rcgen` + `rustls` (TLS).
Happy to discuss any of the implementation decisions.
https://github.com/razvandimescu/numa
---
### r/degoogle
**Title:** I replaced cloud DNS with a recursive resolver — resolves from root, no upstream, DNSSEC
**Body:**
I wanted a DNS setup with zero cloud dependency. No NextDNS account,
no Cloudflare dashboard, no Pi-hole appliance, no upstream resolver seeing
my queries. Just a single binary on my laptop that resolves everything
itself.
Built one in Rust. What it does:
- **Forward mode by default** — transparent proxy to your existing DNS with
caching and ad blocking. Changes nothing about your network.
- **Recursive resolution** — set `mode = "recursive"` and it resolves directly
from root nameservers. No Quad9, no Cloudflare, no upstream dependency.
Each authoritative server only sees the query for its zone — no single
entity sees your full browsing pattern.
- **DNSSEC validation** — verifies the chain of trust from root KSK.
Responses are cryptographically verified — no one can tamper with them
in transit.
- **System-level ad blocking** — Hagezi Pro list (~385K+ domains),
works on any network. Coffee shop WiFi, airport, hotel.
- **ISP resistant** — in recursive mode, if UDP is blocked Numa switches
to TCP automatically. Or set `mode = "auto"` to probe on startup and
fall back to encrypted DoH if needed.
- **Query minimization** — root servers only see the TLD (.com), not
your full domain. RFC 7816.
- **Zero telemetry, zero cloud** — all data stays on your machine. No
account, no login, no analytics. Config is a single TOML file.
- **Local service naming** — bonus for developers: `https://app.numa`
instead of `localhost:3000`, with auto-generated TLS certs
Single binary, macOS + Linux. `sudo numa install` and it's your system
DNS — forward mode by default, recursive when you're ready. No Docker,
no PHP, no external dependencies.
The DNS wire protocol is parsed from scratch — no DNS libraries. You can
read every line of code.
```
brew install razvandimescu/tap/numa
# or
cargo install numa
```
MIT license. https://github.com/razvandimescu/numa
---
### r/node
**Title:** I replaced localhost:5173 with frontend.numa — auto HTTPS, HMR works, no nginx
**Body:**
Running a Vite frontend on :5173, Express API on :3000, maybe docs on
:4000 — I could never remember which port was which. And CORS between
`localhost:5173` and `localhost:3000` is its own special hell.
How do you get named domains with HTTPS locally?
1. /etc/hosts + mkcert + nginx
2. dnsmasq + mkcert + Caddy
3. `sudo numa`
What it actually does:
```
curl -X POST localhost:5380/services \
-d '{"name":"frontend","target_port":5173}'
```
Now `https://frontend.numa` works in my browser. Green lock, valid cert.
- **HMR works** — Vite, webpack, socket.io all pass through the proxy.
No special config.
- **CORS solved** — `frontend.numa` and `api.numa` share the `.numa`
cookie domain. Cross-service auth just works.
- **Path routing** — `app.numa/api → :3000`, `app.numa/auth → :3001`.
Like nginx location blocks, zero config files.
No mkcert, no nginx.conf, no Caddyfile, no editing /etc/hosts.
Single binary, one command.
```
brew install razvandimescu/tap/numa
# or
cargo install numa
```
https://github.com/razvandimescu/numa
---
### r/dns
**Title:** Numa — recursive DNS resolver from scratch in Rust, DNSSEC, no DNS libraries
**Body:**
I built a recursive DNS resolver where the entire wire protocol (RFC 1035 —
headers, label compression, EDNS0) is hand-parsed. No `hickory-dns`,
no `trust-dns`.
What it does:
- Full recursive resolver from root hints (iterative queries, no upstream needed)
- DNSSEC chain-of-trust validation (RSA/SHA-256, ECDSA P-256, Ed25519)
- EDNS0 with DO bit, 1232-byte payload (DNS Flag Day 2020 compliant)
- DNS-over-HTTPS as an alternative upstream mode
- Ad blocking (~385K+ domains via Hagezi Pro)
- Conditional forwarding (auto-detects Tailscale/VPN split-DNS)
- Local zones, ephemeral overrides with auto-revert via REST API
DNSSEC implementation: DNSKEY/DS/RRSIG record parsing, canonical wire format
for signed data, key tag computation (RFC 4034), DS digest verification.
Chain walks from zone → TLD → root trust anchor. ECDSA P-256 signature
verification in 174ns. TLD chain pre-warmed on startup. Referral DS records
piggybacked from authority sections during resolution.
NSEC/NSEC3 authenticated denial of existence: NXDOMAIN gap proofs, NSEC3
closest encloser proofs (3-part per RFC 5155), NODATA type absence proofs,
authority-section RRSIG verification. Iteration cap at 500 for NSEC3 DoS
prevention.
What it doesn't do (yet): no authoritative zone serving (AXFR/NOTIFY).
Single binary, macOS + Linux. MIT license.
https://github.com/razvandimescu/numa
---
### Lobsters (invite-only)
**Title:** Numa — DNS resolver from scratch in Rust, no DNS libraries
**Body:**
I built a DNS resolver in Rust — RFC 1035 wire protocol parsed by hand,
no `trust-dns` or `hickory-dns`. Started as a learning project, became
my daily system DNS.
Beyond resolving, it does local `.numa` domains with auto HTTPS reverse
proxy (register `frontend.numa → localhost:5173`, get a green lock and
WebSocket passthrough), and LAN service discovery via UDP multicast —
two machines running Numa find each other's services automatically.
Implementation bits I found interesting: DNS label compression (chained
2-byte pointers back into the packet), browsers rejecting wildcard certs
under single-label TLDs (`*.numa` fails — need per-service SANs), and
`SO_REUSEPORT` on macOS for multiple processes binding the same multicast
port.
Set `mode = "recursive"` for DNSSEC-validated resolution from root
nameservers — no upstream, no middleman.
Single binary, macOS + Linux.
https://github.com/razvandimescu/numa
---
### r/coolgithubprojects
**Post type:** Image post with `hero-demo.gif`, GitHub link in first comment.
**Title:** Numa — portable DNS resolver built from scratch in Rust. Ad blocking, local HTTPS domains, LAN discovery, recursive resolution with DNSSEC. Single binary.
**First comment (post immediately):**
https://github.com/razvandimescu/numa
```
brew install razvandimescu/tap/numa && sudo numa
```
No DNS libraries — RFC 1035 wire protocol parsed by hand.
Recursive resolution from root nameservers with full DNSSEC
chain-of-trust validation. 385K+ blocked ad domains.
.numa local domains with auto TLS and WebSocket proxy.
---
### r/sideproject
**Title:** I built a DNS resolver from scratch in Rust — it's now my daily system DNS
**Body:**
Last year I wanted to understand how DNS actually works at the wire
level, so I started parsing RFC 1035 packets by hand. No DNS libraries,
no trust-dns, no hickory-dns — just bytes and the spec.
It turned into something I use every day. What it does now:
- **Ad blocking** on any network (coffee shops, airports) — 385K+
domains blocked, travels with my laptop
- **Local service naming** — `https://frontend.numa` instead of
`localhost:5173`, with auto-generated TLS certs and WebSocket
passthrough for HMR
- **Recursive resolution** from root nameservers with DNSSEC
chain-of-trust validation — set `mode = "recursive"` for full
privacy, no upstream dependency, no single entity sees my query
pattern
- **LAN discovery** — two machines running Numa find each other's
services automatically via mDNS
Single Rust binary, ~8MB, MIT license. `sudo numa install` and it's your
system DNS — caching, ad blocking, .numa domains, zero config changes.
I wrote about the technical journey here:
- [I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html)
- [Implementing DNSSEC from Scratch](https://numa.rs/blog/posts/dnssec-from-scratch.html)
https://github.com/razvandimescu/numa
---
### r/webdev (Showoff Saturday — posted 2026-03-28)
**Title:** I replaced localhost:5173 with frontend.numa — shared cookie domain, auto HTTPS, no nginx
**Body:**
The port numbers weren't the real problem. It was CORS between
`localhost:5173` and `localhost:3000`, Secure cookies not setting over
HTTP, and service workers requiring a secure context.
I built a DNS resolver that gives local services named domains under a
shared TLD:
```
curl -X POST localhost:5380/services \
-d '{"name":"frontend","target_port":5173}'
```
Now `https://frontend.numa` and `https://api.numa` share the `.numa`
cookie domain. Cross-service auth just works. Secure cookies set.
Service workers run.
What's under the hood:
- **Auto HTTPS** — generates a local CA + per-service TLS certs. Green
lock, no mkcert.
- **WebSocket passthrough** — Vite/webpack HMR goes through the proxy.
No special config.
- **Path routing** — `app.numa/api → :3000`, `app.numa/auth → :3001`.
Like nginx location blocks.
- **Also a full DNS resolver** — forward mode with caching and ad
blocking by default. Set `mode = "recursive"` for full DNSSEC-validated
resolution from root nameservers.
Single Rust binary. `sudo numa install` and it's your system DNS — caching,
ad blocking, .numa domains. No nginx, no Caddy, no /etc/hosts.
```
brew install razvandimescu/tap/numa
# or
cargo install numa
```
https://github.com/razvandimescu/numa
**Lessons from r/node (2026-03-24):** "Can't remember 3 ports?" got
pushback — the CORS/cookie angle resonated more. Lead with what you
can't do without it, not what's annoying.
---
### r/commandline
**Title:** numa — local dev DNS with auto HTTPS and LAN service discovery, single Rust binary
**Body:**
I run 5-6 local services and wanted named domains with HTTPS instead of
remembering port numbers. Built a DNS resolver that handles `.numa`
domains:
```
curl -X POST localhost:5380/services \
-d '{"name":"api","target_port":8000}'
```
Now `https://api.numa` resolves, proxies to localhost:8000, and has a
valid TLS cert. WebSocket passthrough works — Vite HMR goes through
the proxy fine.
The part I didn't expect to be useful: LAN service discovery. Two
machines running numa find each other via UDP multicast. I register
`api.numa` on my laptop, my teammate's numa instance picks it up
automatically. Zero config.
Also blocks ~385K+ ad domains since it's already your DNS resolver.
Portable — works on any network (coffee shops, airports). Set
`mode = "recursive"` for full DNSSEC-validated resolution from root
nameservers — no upstream dependency.
```
brew install razvandimescu/tap/numa
sudo numa
```
Single binary, DNS wire protocol parsed from scratch (no DNS libraries).
https://github.com/razvandimescu/numa
---
### r/selfhosted (only if Show HN hits front page)
**Title:** Numa — recursive resolver + ad blocking + LAN service discovery in one binary
**Body:**
I built a DNS resolver in Rust that I've been running as my system DNS.
Two features I'm most proud of:
**Recursive resolution + DNSSEC** — set `mode = "recursive"` and it resolves
from root nameservers, no upstream dependency. Chain-of-trust verification
(RSA, ECDSA, Ed25519), NSEC/NSEC3 denial proofs. No single entity sees your
full query pattern — each authoritative server only sees its zone's queries.
**LAN service discovery** — I register `api.numa → localhost:8000` on my
laptop. My colleague's machine, also running Numa, picks it up via UDP
multicast — `api.numa` resolves to my IP on his machine. Zero config.
The rest of what it does:
- **Ad blocking** — ~385K+ domains (Hagezi Pro), portable. Works on any
network including coffee shops and airports.
- **DNS-over-HTTPS** — encrypted upstream as an alternative to recursive mode.
- **Auto HTTPS for local services** — generates a local CA + per-service
TLS certs. `https://frontend.numa` with a green lock, WebSocket passthrough.
- **Hub mode** — point other devices' DNS to it, they get ad blocking +
`.numa` resolution without installing anything.
Replaces Pi-hole + Unbound in one binary. No Raspberry Pi, no Docker, no PHP.
Single binary, macOS + Linux. Config is one optional TOML file.
**What it doesn't do (yet):** No web-based config editor (TOML + REST API).
DoT listener is in progress.
`brew install razvandimescu/tap/numa` or `cargo install numa`
https://github.com/razvandimescu/numa
---
## Preparation Checklist
- [ ] Verify GitHub repo is PUBLIC before any post
- [ ] Build some comment history on posting account first
- [ ] Post HN Tuesday-Thursday, 9-10 AM Eastern
- [ ] Respond to every comment within 2 hours for the first 6 hours
- [ ] Have fixes ready to ship within 24h for reported issues
- [ ] Don't oversell the pkarr/token vision — one sentence max
## Rules
- Verify GitHub repo is PUBLIC before every post
- Use an account with comment history, not a fresh one
- Respond to every comment within 2 hours
- Never be defensive — acknowledge valid criticism, redirect
- If someone says "just use X" — agree it works, explain what's *uniquely different*
- Lead with unique capabilities, not tool replacement
---
## Prepared Responses
**"What does this offer over /etc/hosts?"** *(actual r/programare objection)*
/etc/hosts is static and per-machine. Numa gives you: auto-revert after N
minutes (great for testing), a REST API so scripts can create/remove entries,
HTTPS reverse proxy with auto TLS, and LAN discovery so you don't have to
edit hosts on every device. Different tools for different problems.
**"Mature solutions already exist (dnsmasq, nginx, etc.)"** *(actual r/programare objection)*
Absolutely — and they're great. The thing they don't do: register a service
on machine A and have it automatically appear on machine B via multicast.
Numa integrates DNS + reverse proxy + TLS + discovery into one binary so
those pieces work together. If you only need DNS forwarding, dnsmasq is the
right tool.
**"Why not Pi-hole / AdGuard Home?"**
They're network appliances — need dedicated hardware or Docker. Numa is a
single binary on your laptop. When you move to a coffee shop, your ad
blocking comes with you. Plus the reverse proxy + LAN discovery.
**"Why from scratch / no DNS libraries?"**
Started as a learning project to understand the wire protocol. Turned out
having full control over the pipeline makes features like conditional
forwarding and override injection trivial — they're just steps in the
resolution chain.
**"Vibe coded / AI generated?"**
I use AI as a coding partner — same as using Stack Overflow or pair
programming. I make the architecture decisions, direct what gets built,
and review everything. The DNS wire protocol parser was the original
learning project I wrote by hand. Later features were built collaboratively
with AI assistance. You can read every line — nothing is opaque generated
slop.
**"Why sudo / why port 53?"**
Port 53 requires root on Unix. Numa only needs it for the UDP socket.
You can also bind to a high port for testing: `bind_addr = "127.0.0.1:5353"`.
**"What about .numa TLD conflicts?"**
The TLD is configurable in `numa.toml`. If `.numa` ever becomes official,
change it to anything else.
**"Does it support DoH/DoT?"**
DoH is built in — set `address = "https://9.9.9.9/dns-query"` in
`[upstream]` and your queries are encrypted. Or set `mode = "auto"` to
probe root servers and fall back to DoH if blocked. DoT listener support
is in progress (PR #25).
**"But Quad9/Cloudflare still sees my queries"**
In forward mode (the default), yes — your upstream resolver sees your queries.
Set `mode = "recursive"` and Numa resolves directly from root nameservers —
no single upstream sees your full query pattern. Each authoritative server
only sees the query relevant to its zone. Add `[dnssec] enabled = true` to
cryptographically verify responses.
**"Show me benchmarks / performance numbers"** *(actual r/programare request)*
Benchmark suite is in `benches/` (criterion). Cached round-trip: 691ns.
Pipeline throughput: ~2.0M qps. DNSSEC: ECDSA P-256 verify 174ns, RSA/SHA-256
10.9µs, DS verify 257ns. Cold-cache DNSSEC validation ~90ms (1 network fetch,
TLD chain pre-warmed). Full comparison against system resolver, Quad9,
Cloudflare, Google on the site.
**"Why not just use Unbound?"**
Numa supports recursive resolution with DNSSEC validation, same as Unbound
(`mode = "recursive"`). The difference:
Numa also has built-in ad blocking, a dashboard, `.numa` local domains with
auto HTTPS, LAN service discovery, and developer overrides. Unbound does
one thing well; Numa integrates six features into one binary.
**"Why not Technitium?"**
Technitium is the closest in features — recursive, DNSSEC, ad blocking,
dashboard. Good tool. Two differences: (1) Numa is a single static binary,
Technitium requires the .NET runtime; (2) Numa has developer tooling that
Technitium doesn't — `.numa` local domains with auto TLS reverse proxy,
path-based routing, LAN service discovery, ephemeral overrides with
auto-revert. Different audiences: Technitium targets server admins, Numa
targets developers on laptops.
**"Does it support Windows?"**
macOS and Linux are the primary targets. Windows has scaffolding in the code
but is not tested. If there's demand, it's on the list.

View File

@@ -162,29 +162,6 @@ pub async fn handle_query(
resp.header.authed_data = true;
}
(resp, QueryPath::Cached, cached_dnssec)
} else if let Some(fwd_addr) =
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules)
{
// Conditional forwarding takes priority over recursive mode
// (e.g. Tailscale .ts.net, VPC private zones)
let upstream = Upstream::Udp(fwd_addr);
match forward_query(&query, &upstream, ctx.timeout).await {
Ok(resp) => {
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
}
Err(e) => {
error!(
"{} | {:?} {} | FORWARD ERROR | {}",
src_addr, qtype, qname, e
);
(
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
DnssecStatus::Indeterminate,
)
}
}
} else if ctx.upstream_mode == UpstreamMode::Recursive {
let key = (qname.clone(), qtype);
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {

View File

@@ -20,9 +20,6 @@ use numa::system_dns::{
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
};
const QUAD9_IP: &str = "9.9.9.9";
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
#[tokio::main]
async fn main() -> numa::Result<()> {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
@@ -129,7 +126,7 @@ async fn main() -> numa::Result<()> {
.use_rustls_tls()
.build()
.unwrap_or_default();
let url = DOH_FALLBACK.to_string();
let url = "https://dns.quad9.net/dns-query".to_string();
let label = url.clone();
(
numa::config::UpstreamMode::Forward,
@@ -155,7 +152,7 @@ async fn main() -> numa::Result<()> {
.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()
"https://dns.quad9.net/dns-query".to_string()
})
} else {
config.upstream.address.clone()
@@ -481,14 +478,7 @@ async fn main() -> numa::Result<()> {
#[allow(clippy::infinite_loop)]
loop {
let mut buffer = BytePacketBuffer::new();
let (_, src_addr) = match 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 (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?;
let ctx = Arc::clone(&ctx);
tokio::spawn(async move {
@@ -531,7 +521,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
let new_addr = dns_info
.default_upstream
.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) =
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
{

View File

@@ -47,19 +47,16 @@ impl SrttCache {
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
fn decayed_srtt(entry: &SrttEntry) -> u64 {
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
}
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
let age_secs = entry.updated_at.elapsed().as_secs();
if age_secs > DECAY_AFTER_SECS {
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
let mut srtt = srtt_ms;
let mut srtt = entry.srtt_ms;
for _ in 0..periods {
srtt = (srtt + INITIAL_SRTT_MS) / 2;
}
srtt
} else {
srtt_ms
entry.srtt_ms
}
}
@@ -119,6 +116,13 @@ impl SrttCache {
self.entries.is_empty()
}
#[cfg(test)]
fn set_updated_at(&mut self, ip: IpAddr, at: Instant) {
if let Some(entry) = self.entries.get_mut(&ip) {
entry.updated_at = at;
}
}
fn maybe_evict(&mut self) {
if self.entries.len() < MAX_ENTRIES {
return;
@@ -214,41 +218,63 @@ mod tests {
assert_eq!(addrs, original);
}
fn age(secs: u64) -> Instant {
Instant::now() - std::time::Duration::from_secs(secs)
}
/// Cache with ip(1) saturated at FAILURE_PENALTY_MS
fn saturated_penalty_cache() -> SrttCache {
let mut cache = SrttCache::new(true);
for _ in 0..30 {
cache.record_rtt(ip(1), FAILURE_PENALTY_MS, false);
}
cache
}
#[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);
let mut cache = SrttCache::new(true);
cache.record_rtt(ip(1), 5000, false);
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS));
assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_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);
let mut cache = saturated_penalty_cache();
let raw = cache.entries[&ip(1)].srtt_ms;
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1));
let expected = (raw + INITIAL_SRTT_MS) / 2;
assert_eq!(cache.get(ip(1)), 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;
let mut cache = saturated_penalty_cache();
let raw = cache.entries[&ip(1)].srtt_ms;
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1));
let mut expected = raw;
for _ in 0..4 {
expected = (expected + INITIAL_SRTT_MS) / 2;
}
assert_eq!(result, expected);
assert_eq!(cache.get(ip(1)), 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);
let mut cache_a = saturated_penalty_cache();
let mut cache_b = saturated_penalty_cache();
cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1));
cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1)));
}
#[test]
fn decay_converges_toward_initial() {
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
let mut cache = saturated_penalty_cache();
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
let decayed = cache.get(ip(1));
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
assert!(
diff < 25,
@@ -260,28 +286,29 @@ mod tests {
#[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
);
let mut cache = saturated_penalty_cache();
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8));
cache.record_rtt(ip(1), 50, false);
let srtt = cache.get(ip(1));
// Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381
assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt);
}
#[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
);
let mut cache = saturated_penalty_cache();
for _ in 0..30 {
cache.record_rtt(ip(2), 300, false);
}
let mut addrs = vec![sock(1), sock(2)];
cache.sort_by_rtt(&mut addrs);
assert_eq!(addrs, vec![sock(2), sock(1)]);
// Age server 1 so it decays toward INITIAL (200ms) — below server 2's 300ms
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
let mut addrs = vec![sock(1), sock(2)];
cache.sort_by_rtt(&mut addrs);
assert_eq!(addrs, vec![sock(1), sock(2)]);
}
#[test]

View File

@@ -334,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo {
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
if let Some(ip) = trimmed.split(':').next_back() {
let ip = ip.trim();
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
if !is_loopback_or_stub(ip) {
upstream = Some(ip.to_string());
break;
}
@@ -358,339 +358,6 @@ fn discover_windows() -> SystemDnsInfo {
}
}
#[cfg(any(windows, test))]
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct WindowsInterfaceDns {
dhcp: bool,
servers: Vec<String>,
}
#[cfg(any(windows, test))]
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
let mut interfaces = std::collections::HashMap::new();
let mut current_adapter: Option<String> = None;
let mut current_dhcp = false;
let mut current_dns: Vec<String> = Vec::new();
let mut in_dns_block = false;
let mut disconnected = false;
for line in text.lines() {
let trimmed = line.trim();
// Adapter section headers start at column 0
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
if let Some(name) = current_adapter.take() {
if !disconnected {
interfaces.insert(
name,
WindowsInterfaceDns {
dhcp: current_dhcp,
servers: std::mem::take(&mut current_dns),
},
);
}
current_dns.clear();
}
in_dns_block = false;
current_dhcp = false;
disconnected = false;
// "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German)
let lower = trimmed.to_lowercase();
if let Some(pos) = lower.find(" adapter ") {
let after = &trimmed[pos + " adapter ".len()..];
let name = after.trim_end_matches(':').trim();
if !name.is_empty() {
current_adapter = Some(name.to_string());
}
}
} else if current_adapter.is_some() {
if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") {
disconnected = true;
} else if trimmed.contains("DHCP") && trimmed.contains(". .") {
current_dhcp = trimmed
.split(':')
.next_back()
.map(|v| {
let v = v.trim().to_lowercase();
v == "yes" || v == "ja"
})
.unwrap_or(false);
in_dns_block = false;
} else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
in_dns_block = true;
if let Some(ip) = trimmed.split(':').next_back() {
let ip = ip.trim();
if ip.parse::<std::net::IpAddr>().is_ok() {
current_dns.push(ip.to_string());
}
}
} else if in_dns_block {
if trimmed.parse::<std::net::IpAddr>().is_ok() {
current_dns.push(trimmed.to_string());
} else {
in_dns_block = false;
}
}
}
}
if let Some(name) = current_adapter {
if !disconnected {
interfaces.insert(
name,
WindowsInterfaceDns {
dhcp: current_dhcp,
servers: current_dns,
},
);
}
}
interfaces
}
#[cfg(windows)]
fn get_windows_interfaces() -> Result<std::collections::HashMap<String, WindowsInterfaceDns>, String>
{
let output = std::process::Command::new("ipconfig")
.arg("/all")
.output()
.map_err(|e| format!("failed to run ipconfig /all: {}", e))?;
let text = String::from_utf8_lossy(&output.stdout);
Ok(parse_ipconfig_interfaces(&text))
}
#[cfg(windows)]
fn windows_backup_path() -> std::path::PathBuf {
// Use ProgramData (not APPDATA) since install requires admin elevation
// and APPDATA differs between user and admin contexts.
std::path::PathBuf::from(
std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()),
)
.join("numa")
.join("original-dns.json")
}
#[cfg(windows)]
fn disable_dnscache() -> Result<bool, String> {
// Check if Dnscache is running (it holds port 53 at kernel level)
let output = std::process::Command::new("sc")
.args(["query", "Dnscache"])
.output()
.map_err(|e| format!("failed to query Dnscache: {}", e))?;
let text = String::from_utf8_lossy(&output.stdout);
if !text.contains("RUNNING") {
return Ok(false);
}
eprintln!(" Disabling DNS Client (Dnscache) to free port 53...");
// Dnscache can't be stopped via sc/net stop — must disable via registry
let status = std::process::Command::new("reg")
.args([
"add",
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
"/v",
"Start",
"/t",
"REG_DWORD",
"/d",
"4",
"/f",
])
.status()
.map_err(|e| format!("failed to disable Dnscache: {}", e))?;
if !status.success() {
return Err("failed to disable Dnscache via registry (run as Administrator?)".into());
}
eprintln!(" Dnscache disabled. A reboot is required to free port 53.");
Ok(true)
}
#[cfg(windows)]
fn enable_dnscache() {
let _ = std::process::Command::new("reg")
.args([
"add",
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
"/v",
"Start",
"/t",
"REG_DWORD",
"/d",
"2",
"/f",
])
.status();
}
#[cfg(windows)]
fn install_windows() -> Result<(), String> {
let interfaces = get_windows_interfaces()?;
if interfaces.is_empty() {
return Err("no active network interfaces found".to_string());
}
let path = windows_backup_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
}
let json = serde_json::to_string_pretty(&interfaces)
.map_err(|e| format!("failed to serialize backup: {}", e))?;
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
for name in interfaces.keys() {
let status = std::process::Command::new("netsh")
.args([
"interface",
"ipv4",
"set",
"dnsservers",
name,
"static",
"127.0.0.1",
"primary",
])
.status()
.map_err(|e| format!("failed to set DNS for {}: {}", name, e))?;
if status.success() {
eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name);
} else {
eprintln!(
" warning: failed to set DNS for \"{}\" (run as Administrator?)",
name
);
}
}
let needs_reboot = disable_dnscache()?;
register_autostart();
eprintln!("\n Original DNS saved to {}", path.display());
eprintln!(" Run 'numa uninstall' to restore.\n");
if needs_reboot {
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
} else {
eprintln!(" Numa will start automatically on next boot.\n");
}
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
Ok(())
}
/// Register numa to auto-start on boot via registry Run key.
#[cfg(windows)]
fn register_autostart() {
let exe = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "numa".into());
let _ = std::process::Command::new("reg")
.args([
"add",
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
"/v",
"Numa",
"/t",
"REG_SZ",
"/d",
&exe,
"/f",
])
.status();
eprintln!(" Registered auto-start on boot.");
}
/// Remove numa auto-start registry key.
#[cfg(windows)]
fn remove_autostart() {
let _ = std::process::Command::new("reg")
.args([
"delete",
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
"/v",
"Numa",
"/f",
])
.status();
}
#[cfg(windows)]
fn uninstall_windows() -> Result<(), String> {
remove_autostart();
let path = windows_backup_path();
let json = std::fs::read_to_string(&path)
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?;
for (name, dns_info) in &original {
if dns_info.dhcp || dns_info.servers.is_empty() {
let status = std::process::Command::new("netsh")
.args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"])
.status()
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
if status.success() {
eprintln!(" restored DNS for \"{}\" -> DHCP", name);
} else {
eprintln!(" warning: failed to restore DNS for \"{}\"", name);
}
} else {
let status = std::process::Command::new("netsh")
.args([
"interface",
"ipv4",
"set",
"dnsservers",
name,
"static",
&dns_info.servers[0],
"primary",
])
.status()
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
if !status.success() {
eprintln!(" warning: failed to restore primary DNS for \"{}\"", name);
continue;
}
for (i, server) in dns_info.servers.iter().skip(1).enumerate() {
let _ = std::process::Command::new("netsh")
.args([
"interface",
"ipv4",
"add",
"dnsservers",
name,
server,
&format!("index={}", i + 2),
])
.status();
}
eprintln!(
" restored DNS for \"{}\" -> {}",
name,
dns_info.servers.join(", ")
);
}
}
std::fs::remove_file(&path).ok();
// Re-enable Dnscache
enable_dnscache();
eprintln!("\n System DNS restored. DNS Client re-enabled.");
eprintln!(" Reboot to fully restore the DNS Client service.\n");
Ok(())
}
/// Find the upstream for a domain by checking forwarding rules.
/// Returns None if no rule matches (use default upstream).
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
@@ -776,7 +443,7 @@ fn install_macos() -> Result<(), String> {
.map_err(|e| format!("failed to serialize backup: {}", e))?;
std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?;
// Set DNS to 127.0.0.1 and add "numa" search domain for each service
// Set DNS to 127.0.0.1 for each service
for service in &services {
let status = std::process::Command::new("networksetup")
.args(["-setdnsservers", service, "127.0.0.1"])
@@ -788,11 +455,6 @@ fn install_macos() -> Result<(), String> {
} else {
eprintln!(" warning: failed to set DNS for \"{}\"", service);
}
// Add "numa" as search domain so browsers resolve .numa without trailing slash
let _ = std::process::Command::new("networksetup")
.args(["-setsearchdomains", service, "numa"])
.status();
}
eprintln!("\n Original DNS saved to {}", backup_path().display());
@@ -837,11 +499,6 @@ fn uninstall_macos() -> Result<(), String> {
} else {
eprintln!(" warning: failed to restore DNS for \"{}\"", service);
}
// Clear the "numa" search domain
let _ = std::process::Command::new("networksetup")
.args(["-setsearchdomains", service, "Empty"])
.status();
}
std::fs::remove_file(&path).ok();
@@ -865,9 +522,7 @@ pub fn install_service() -> Result<(), String> {
let result = install_service_macos();
#[cfg(target_os = "linux")]
let result = install_service_linux();
#[cfg(windows)]
let result = install_windows();
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let result = Err::<(), String>("service installation not supported on this OS".to_string());
if result.is_ok() {
@@ -891,11 +546,7 @@ pub fn uninstall_service() -> Result<(), String> {
{
uninstall_service_linux()
}
#[cfg(windows)]
{
uninstall_windows()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
Err("service uninstallation not supported on this OS".to_string())
}
@@ -1102,7 +753,7 @@ fn install_linux() -> Result<(), String> {
let drop_in = resolved_dir.join("numa.conf");
std::fs::write(
&drop_in,
"[Resolve]\nDNS=127.0.0.1\nDomains=~. numa\nDNSStubListener=no\n",
"[Resolve]\nDNS=127.0.0.1\nDomains=~.\nDNSStubListener=no\n",
)
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
@@ -1140,7 +791,7 @@ fn install_linux() -> Result<(), String> {
}
let content =
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\nsearch numa\n";
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n";
std::fs::write(resolv, content)
.map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?;
@@ -1376,57 +1027,3 @@ fn untrust_ca() -> Result<(), String> {
let _ = ca_path; // suppress unused warning on other platforms
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_ipconfig_dhcp_and_static() {
let sample = "\
Ethernet adapter Ethernet:
DHCP Enabled. . . . . . . . . . . : Yes
DNS Servers . . . . . . . . . . . : 8.8.8.8
8.8.4.4
Wireless LAN adapter Wi-Fi:
DHCP Enabled. . . . . . . . . . . : No
DNS Servers . . . . . . . . . . . : 1.1.1.1
";
let result = parse_ipconfig_interfaces(sample);
assert_eq!(result.len(), 2);
assert_eq!(
result["Ethernet"],
WindowsInterfaceDns {
dhcp: true,
servers: vec!["8.8.8.8".into(), "8.8.4.4".into()],
}
);
assert_eq!(
result["Wi-Fi"],
WindowsInterfaceDns {
dhcp: false,
servers: vec!["1.1.1.1".into()],
}
);
}
#[test]
fn parse_ipconfig_skips_disconnected() {
let sample = "\
Ethernet adapter Ethernet 2:
Media State . . . . . . . . . . . : Media disconnected
Wireless LAN adapter Wi-Fi:
DHCP Enabled. . . . . . . . . . . : Yes
DNS Servers . . . . . . . . . . . : 192.168.1.1
";
let result = parse_ipconfig_interfaces(sample);
assert_eq!(result.len(), 1);
assert!(result.contains_key("Wi-Fi"));
}
}