81 Commits

Author SHA1 Message Date
Razvan Dimescu
6a70ab0f1b chore: bump version to 0.9.0
New: Windows DNS configuration (install/uninstall/auto-start).
Fix: DoH fallback uses IP to avoid DNS bootstrap loop.
Fix: UDP ConnectionReset crash on Windows.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 18:22:18 +03:00
Razvan Dimescu
0b883d1c0d feat: Windows DNS configuration via netsh (#28)
* feat: Windows DNS configuration via netsh

numa install/uninstall now set/restore system DNS on Windows via
netsh. Parses ipconfig /all per-interface (adapter name, DHCP status,
DNS servers), saves backup to %APPDATA%\numa\original-dns.json, and
restores on uninstall (DHCP or static with secondary servers).

Handles localization (German adapter/DHCP/DNS labels), disconnected
adapters, multiple interfaces, and missing admin privileges. Adds IP
validation to discover_windows() for consistency.

No Windows Service or CA trust yet — user runs numa in a terminal.

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

* ci: add cargo test to Windows CI job

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

* ci: upload Windows binary as artifact for testing

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

* fix: SRTT decay tests panic on Windows due to Instant underflow

On Windows, Instant starts near boot time — subtracting large
durations panics. Use checked_sub with a process-start fallback.

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

* fix: SRTT decay tests use binary search for max Instant age

Replace age() helper with set_age_secs() on SrttCache that
binary-searches for the maximum subtractable duration. Prevents
panic on Windows (Instant starts at boot) while still producing
the oldest representable instant for correct decay calculations.

Also removes ephemeral test-ubuntu.sh from git.

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

* fix: use ProgramData for Windows DNS backup path

APPDATA differs between user and admin contexts — install runs as
admin but uninstall might resolve a different APPDATA. Use
ProgramData which is consistent across elevation contexts.

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

* feat: disable Dnscache on Windows install, re-enable on uninstall

Windows DNS Client (Dnscache) holds port 53 at kernel level and
can't be stopped via sc/net stop. Disable via registry during
install (requires reboot), re-enable on uninstall.

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

* fix: rewrite SRTT decay tests as pure functions

Decay tests manipulated Instant timestamps which panics on Windows
(Instant can't go before boot time). Rewrite to test decay_for_age()
directly — a pure function taking srtt_ms and age_secs, no platform
dependency.

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

* fix: use Quad9 IP (9.9.9.9) for DoH fallback, not hostname

DoH to dns.quad9.net requires DNS to resolve the hostname, which
creates a chicken-and-egg loop when numa IS the system resolver
(e.g. after numa install on Windows). Using the IP directly avoids
the bootstrap dependency.

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

* refactor: extract DOH_FALLBACK constant

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

* refactor: extract QUAD9_IP constant

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

* refactor: remove dead test helpers, fix constant placement

Remove unused get_srtt_ms() and saturated_penalty_cache() left over
from SRTT test rewrite. Move QUAD9_IP/DOH_FALLBACK after use block.

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

* fix: ignore ConnectionReset on UDP socket (Windows ICMP error)

Windows delivers ICMP port-unreachable as ConnectionReset on the
next UDP recv_from, crashing numa. Linux/macOS silently ignore these.
Catch and continue the recv loop.

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

* feat: auto-start numa on Windows boot via registry Run key

Without a Windows Service, rebooting after numa install leaves DNS
broken (pointing at 127.0.0.1 with nothing listening). Register
numa in HKLM\...\Run so it starts automatically. Removed on
uninstall.

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

* docs: update README, Windows plan, and launch drafts for Windows support

- README: platform-specific Quick Start, install/uninstall table
- Windows plan: Phase 2 complete, Phase 3 scoped
- Launch drafts: updated "Does it support Windows?" response

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

* chore: remove docs from git tracking (already gitignored)

docs/ is in .gitignore but files were force-added. Remove from
tracking — files remain on disk.

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 18:17:52 +03:00
Razvan Dimescu
7f46f6271e docs: surface three resolution modes in README
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 09:28:44 +03:00
Razvan Dimescu
f3ca83246c 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
da93a3cde3 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
98da440c84 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
4e5b88496c 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
d5f7ce9e2d chore: updated install methods 2026-03-29 23:33:45 +03:00
Razvan Dimescu
cc704be590 chore: bump version to 0.7.3 2026-03-29 23:16:46 +03:00
Razvan Dimescu
ff1200eb10 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
49535568d9 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
cd1beedf38 chore: bump version to 0.7.2 2026-03-29 11:44:10 +03:00
Razvan Dimescu
be52e5c305 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
669498e85f 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
d325b92e44 chore: bump version to 0.7.1 2026-03-29 10:39:17 +03:00
Razvan Dimescu
261fd2e148 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
30e46e549c 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
ac49658c2b 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
5265f571d0 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
0ebd924825 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
06d4e91cd2 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
71dbb138bc 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
fbf3ca6d11 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
a84f2e7f1d 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
7aee90c99b 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
1304b1c02c 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
59397ecce4 Change artifact upload path for GitHub Pages 2026-03-27 02:22:43 +02:00
Razvan Dimescu
f849a4d65f 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
962b400f4c 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
1f4063d5db 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
c6bc307f0a 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
c5208e934d 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
d69b79451e 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
0b194256a9 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
e0c1997056 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
9e07064c94 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
43cedf11f7 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
cd6a54c652 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
9f89627c5a 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
e7e5c173f2 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
c6b35045d8 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
10f1602803 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
41a97bb930 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
c4e733c8ef 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
4020776b8e 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
763ba1de91 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
51dc06690e 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
fb89b78226 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
64c4d146ec 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
9c290b6ef4 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
c836903db5 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
5e5a6544bc 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
227af04564 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
4c58ff49b0 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
d261e8bc86 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
2de337ac36 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
5810ee5aac 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
06850de728 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
995916d01b 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
7aca3b1991 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
b7d64a9707 Merge pull request #8 from razvandimescu/feat/windows-support
Add Windows support (Phase 1)
2026-03-22 08:38:10 +02:00
Razvan Dimescu
c333705a0e 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
50d17ae118 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
5495107c9e 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
02e83ccd72 updated hero image 2026-03-22 08:04:37 +02:00
Razvan Dimescu
ccbf893b92 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
cd90b50d68 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
5866ff1ba1 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
9a3de2f231 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
6fdadd637c 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
9041ccc2e1 fix rustfmt formatting
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 16:54:03 +02:00
Razvan Dimescu
c9f1d98f45 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
6a8e47bbb5 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
de50720834 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
216ec76640 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
08aaebec7e 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
3e40f795da 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
8dcebaaca6 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
a48809fc25 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
e94e75101f updated hero 2026-03-21 04:49:18 +02:00
Razvan Dimescu
32f50cd254 Merge pull request #6 from razvandimescu/feat/404-page
Styled 404 page for unregistered .numa domains
2026-03-21 04:33:59 +02:00
23 changed files with 1383 additions and 360 deletions

View File

@@ -37,3 +37,10 @@ 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.7.2"
version = "0.9.0"
dependencies = [
"arc-swap",
"axum",

View File

@@ -1,6 +1,6 @@
[package]
name = "numa"
version = "0.7.2"
version = "0.9.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

@@ -8,20 +8,39 @@
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. Recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded.
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. One ~8MB binary, everything embedded.
![Numa dashboard](assets/hero-demo.gif)
## Quick Start
```bash
brew install razvandimescu/tap/numa # or: cargo install numa
sudo numa # port 53 requires root
# macOS
brew install razvandimescu/tap/numa
# 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)
```
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
Set as system DNS: `sudo numa install && sudo numa service start`
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.
## Local Services
@@ -40,7 +59,13 @@ 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.
Two resolution modes: **forward** (relay to Quad9/Cloudflare via encrypted DoH) or **recursive** (resolve 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)
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)
## LAN Discovery
@@ -71,7 +96,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 |
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
## Performance

View File

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

View File

@@ -54,7 +54,7 @@ enabled = true
port = 80
tls_port = 443
tld = "numa"
# bind_addr = "127.0.0.1" # default; auto 0.0.0.0 when [lan] enabled
# bind_addr = "127.0.0.1" # default; set to "0.0.0.0" for LAN access to .numa services
# Pre-configured services (numa.numa is always added automatically)
# [[services]]

View File

@@ -101,7 +101,7 @@ body {
/* Stat cards row */
.stats-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
gap: 1rem;
}
.stat-card {
@@ -125,6 +125,8 @@ body {
.stat-card.blocked::before { background: var(--rose); }
.stat-card.overrides::before { background: var(--violet); }
.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 {
font-size: 0.7rem;
@@ -468,10 +470,74 @@ body {
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 */
@media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) {
.stats-row { grid-template-columns: repeat(2, 1fr); }
.dashboard { padding: 1rem; }
@@ -524,6 +590,11 @@ body {
<div class="stat-value" id="uptime"></div>
<div class="stat-sub" id="uptimeSub">&nbsp;</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>
<!-- Resolution paths -->
@@ -648,6 +719,17 @@ body {
</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 -->
<div class="panel">
<div class="panel-header">
@@ -712,6 +794,69 @@ function formatRemaining(secs) {
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 = [
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
@@ -882,6 +1027,9 @@ async function refresh() {
document.getElementById('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || '';
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').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
@@ -945,7 +1093,7 @@ async function refresh() {
prevTime = now;
// Cache hit rate
const answered = q.cached + q.forwarded + q.local + q.overridden;
const answered = q.cached + q.forwarded + q.recursive + q.coalesced + q.local + q.overridden;
const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0';
document.getElementById('cacheRate').textContent = hitRate + '%';
@@ -957,6 +1105,7 @@ async function refresh() {
renderServices(services);
renderBlockingInfo(blockingInfo);
renderAllowlist(allowlist);
renderMemory(stats.memory, stats);
} catch (err) {
document.getElementById('statusDot').className = 'status-dot error';
@@ -1236,6 +1385,7 @@ setInterval(refresh, 2000);
Config: <span id="footerConfig" 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>
· Mode: <span id="footerMode" 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>
· 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 name="viewport" content="width=device-width, initial-scale=1.0">
<title>Numa — DNS you own. Everywhere you go.</title>
<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.">
<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.">
<link rel="canonical" href="https://numa.rs">
<meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
<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: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:type" content="website">
<meta property="og:url" content="https://numa.rs">
<link rel="stylesheet" href="/fonts/fonts.css">
@@ -1232,17 +1232,17 @@ footer .closing {
<div class="reveal">
<div class="section-label">How It Works</div>
<h2>What it does today</h2>
<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>
<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>
</div>
<div class="layers-grid">
<div class="layer-card reveal reveal-delay-1">
<div class="layer-badge">Layer 1</div>
<h3>Resolve &amp; Protect</h3>
<ul>
<li>Recursive resolution &mdash; resolve from root nameservers, no upstream needed</li>
<li>DNSSEC validation &mdash; chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
<li>Forward mode by default &mdash; transparent proxy to your existing DNS, with caching</li>
<li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</li>
<li>DNS-over-HTTPS &mdash; encrypted upstream as alternative to recursive mode</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>TTL-aware caching (sub-ms lookups)</li>
<li>Single binary, portable &mdash; macOS, Linux, and Windows</li>
</ul>

View File

@@ -160,6 +160,7 @@ struct QueryLogResponse {
struct StatsResponse {
uptime_secs: u64,
upstream: String,
mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
config_path: String,
data_dir: String,
dnssec: bool,
@@ -169,6 +170,7 @@ struct StatsResponse {
overrides: OverrideStats,
blocking: BlockingStatsResponse,
lan: LanStatsResponse,
memory: MemoryStats,
}
#[derive(Serialize)]
@@ -209,6 +211,19 @@ struct BlockingStatsResponse {
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)]
struct DiagnoseResponse {
domain: String,
@@ -410,14 +425,8 @@ async fn forward_query_for_diagnose(
timeout: std::time::Duration,
) -> (bool, String) {
use crate::packet::DnsPacket;
use crate::question::DnsQuestion;
let mut query = DnsPacket::new();
query.header.id = 0xBEEF;
query.header.recursion_desired = true;
query
.questions
.push(DnsQuestion::new(domain.to_string(), QueryType::A));
let query = DnsPacket::query(0xBEEF, domain, QueryType::A);
match forward_query(&query, upstream, timeout).await {
Ok(resp) => (
@@ -476,12 +485,29 @@ async fn query_log(
async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
let snap = ctx.stats.lock().unwrap().snapshot();
let (cache_len, cache_max) = {
let (cache_len, cache_max, cache_bytes) = {
let cache = ctx.cache.read().unwrap();
(cache.len(), cache.max_entries())
(cache.len(), cache.max_entries(), cache.heap_bytes())
};
let override_count = ctx.overrides.read().unwrap().active_count();
let bl_stats = ctx.blocklist.read().unwrap().stats();
let (override_count, overrides_bytes) = {
let ov = ctx.overrides.read().unwrap();
(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 {
"recursive (root hints)".to_string()
@@ -492,10 +518,11 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
Json(StatsResponse {
uptime_secs: snap.uptime_secs,
upstream,
mode: ctx.upstream_mode.as_str(),
config_path: ctx.config_path.clone(),
data_dir: ctx.data_dir.to_string_lossy().to_string(),
dnssec: ctx.dnssec_enabled,
srtt: ctx.srtt.read().unwrap().is_enabled(),
srtt: srtt_enabled,
queries: QueriesStats {
total: snap.total,
forwarded: snap.forwarded,
@@ -524,6 +551,17 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
enabled: ctx.lan_enabled,
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(),
},
})
}

View File

@@ -183,6 +183,15 @@ impl BlocklistStore {
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 {
BlocklistStats {
enabled: self.is_enabled(),
@@ -234,6 +243,23 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
domains
}
#[cfg(test)]
mod tests {
use super::*;
#[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)> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))

View File

@@ -142,6 +142,26 @@ impl DnsCache {
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) {
let domain_lower = domain.to_lowercase();
if let Some(type_map) = self.entries.remove(&domain_lower) {
@@ -194,3 +214,23 @@ fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) {
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

@@ -59,18 +59,31 @@ fn default_bind_addr() -> String {
"0.0.0.0:53".to_string()
}
pub const DEFAULT_API_PORT: u16 = 5380;
fn default_api_port() -> u16 {
5380
DEFAULT_API_PORT
}
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
#[serde(rename_all = "lowercase")]
pub enum UpstreamMode {
Auto,
#[default]
Forward,
Recursive,
}
impl UpstreamMode {
pub fn as_str(&self) -> &'static str {
match self {
UpstreamMode::Auto => "auto",
UpstreamMode::Forward => "forward",
UpstreamMode::Recursive => "recursive",
}
}
}
#[derive(Deserialize)]
pub struct UpstreamConfig {
#[serde(default)]
@@ -103,10 +116,14 @@ impl Default for UpstreamConfig {
}
}
fn default_srtt() -> bool {
fn default_true() -> bool {
true
}
fn default_srtt() -> bool {
default_true()
}
fn default_prime_tlds() -> Vec<String> {
vec![
// gTLDs

View File

@@ -93,18 +93,13 @@ pub async fn handle_query(
} else if qname == "localhost" || qname.ends_with(".localhost") {
// RFC 6761: .localhost always resolves to loopback
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
match qtype {
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
domain: qname.clone(),
addr: std::net::Ipv6Addr::LOCALHOST,
ttl: 300,
}),
_ => resp.answers.push(DnsRecord::A {
domain: qname.clone(),
addr: std::net::Ipv4Addr::LOCALHOST,
ttl: 300,
}),
}
resp.answers.push(sinkhole_record(
&qname,
qtype,
std::net::Ipv4Addr::LOCALHOST,
std::net::Ipv6Addr::LOCALHOST,
300,
));
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if is_special_use_domain(&qname) {
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
@@ -113,12 +108,17 @@ pub async fn handle_query(
} else if !ctx.proxy_tld_suffix.is_empty()
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
{
// Resolve .numa: local services → 127.0.0.1, LAN peers → peer IP
// Resolve .numa: remote clients get LAN IP (can't reach 127.0.0.1), local get loopback
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 local = ctx.services.lock().unwrap();
if local.lookup(service_name).is_some() {
std::net::Ipv4Addr::LOCALHOST
if is_remote {
*ctx.lan_ip.lock().unwrap()
} else {
std::net::Ipv4Addr::LOCALHOST
}
} else {
let mut peers = ctx.lan_peers.lock().unwrap();
peers
@@ -130,38 +130,24 @@ pub async fn handle_query(
.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);
match qtype {
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.answers
.push(sinkhole_record(&qname, qtype, resolve_ip, v6, 300));
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
match qtype {
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
domain: qname.clone(),
addr: std::net::Ipv6Addr::UNSPECIFIED,
ttl: 60,
}),
_ => resp.answers.push(DnsRecord::A {
domain: qname.clone(),
addr: std::net::Ipv4Addr::UNSPECIFIED,
ttl: 60,
}),
}
resp.answers.push(sinkhole_record(
&qname,
qtype,
std::net::Ipv4Addr::UNSPECIFIED,
std::net::Ipv6Addr::UNSPECIFIED,
60,
));
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
@@ -383,6 +369,27 @@ fn is_special_use_domain(qname: &str) -> bool {
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>>),
@@ -675,15 +682,6 @@ mod tests {
// ---- Integration: resolve_coalesced with mock futures ----
fn mock_query(id: u16, domain: &str, qtype: 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
}
fn mock_response(domain: &str) -> DnsPacket {
let mut resp = DnsPacket::new();
resp.header.response = true;
@@ -706,7 +704,7 @@ mod tests {
let count = resolve_count.clone();
let inf = inflight.clone();
let key = ("coalesce.test".to_string(), QueryType::A);
let query = mock_query(100 + i, "coalesce.test", 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);
@@ -744,8 +742,8 @@ mod tests {
let count1 = resolve_count.clone();
let count2 = resolve_count.clone();
let query_a = mock_query(200, "same.domain", QueryType::A);
let query_aaaa = mock_query(201, "same.domain", QueryType::AAAA);
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(
@@ -788,7 +786,7 @@ mod tests {
#[tokio::test]
async fn inflight_map_cleaned_after_error() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = mock_query(300, "will-fail.test", QueryType::A);
let query = DnsPacket::query(300, "will-fail.test", QueryType::A);
let (_, path, _) = resolve_coalesced(
&inflight,
@@ -809,7 +807,7 @@ mod tests {
let mut handles = Vec::new();
for i in 0..3u16 {
let inf = inflight.clone();
let query = mock_query(400 + i, "fail.test", QueryType::A);
let query = DnsPacket::query(400 + i, "fail.test", QueryType::A);
handles.push(tokio::spawn(async move {
resolve_coalesced(
&inf,
@@ -849,7 +847,7 @@ mod tests {
#[tokio::test]
async fn servfail_leader_includes_question_section() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = mock_query(500, "question.test", QueryType::A);
let query = DnsPacket::query(500, "question.test", QueryType::A);
let (resp, _, _) = resolve_coalesced(
&inflight,
@@ -873,7 +871,7 @@ mod tests {
#[tokio::test]
async fn leader_error_preserves_message() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = mock_query(700, "err-msg.test", QueryType::A);
let query = DnsPacket::query(700, "err-msg.test", QueryType::A);
let (_, path, err) = resolve_coalesced(
&inflight,

View File

@@ -141,7 +141,7 @@ mod tests {
use std::future::IntoFuture;
use crate::header::ResultCode;
use crate::question::{DnsQuestion, QueryType};
use crate::question::QueryType;
use crate::record::DnsRecord;
#[test]
@@ -160,12 +160,7 @@ mod tests {
}
fn make_query() -> DnsPacket {
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
DnsPacket::query(0xABCD, "example.com", QueryType::A)
}
fn make_response(query: &DnsPacket) -> DnsPacket {

View File

@@ -17,10 +17,12 @@ use numa::query_log::QueryLog;
use numa::service_store::ServiceStore;
use numa::stats::ServerStats;
use numa::system_dns::{
discover_system_dns, install_service, install_system_dns, restart_service, service_status,
uninstall_service, uninstall_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"))
@@ -31,12 +33,12 @@ async fn main() -> numa::Result<()> {
let arg1 = std::env::args().nth(1).unwrap_or_default();
match arg1.as_str() {
"install" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n");
return install_system_dns().map_err(|e| e.into());
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n");
return install_service().map_err(|e| e.into());
}
"uninstall" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n");
return uninstall_system_dns().map_err(|e| e.into());
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n");
return uninstall_service().map_err(|e| e.into());
}
"service" => {
let sub = std::env::args().nth(2).unwrap_or_default();
@@ -107,32 +109,81 @@ async fn main() -> numa::Result<()> {
// Discover system DNS in a single pass (upstream + forwarding rules)
let system_dns = discover_system_dns();
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");
"https://dns.quad9.net/dns-query".to_string()
})
} else {
config.upstream.address.clone()
};
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
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,
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
numa::config::UpstreamMode::Auto => {
info!("auto mode: probing recursive resolution...");
if numa::recursive::probe_recursive(&root_hints).await {
info!("recursive probe succeeded — self-sovereign mode");
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
(
numa::config::UpstreamMode::Recursive,
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,
)
}
} 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 mut blocklist = BlocklistStore::new();
@@ -183,7 +234,7 @@ async fn main() -> numa::Result<()> {
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
forwarding_rules,
upstream: Mutex::new(upstream),
upstream_auto: config.upstream.address.is_empty(),
upstream_auto,
upstream_port: config.upstream.port,
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
timeout: Duration::from_millis(config.upstream.timeout_ms),
@@ -199,8 +250,8 @@ async fn main() -> numa::Result<()> {
config_dir: numa::config_dir(),
data_dir: numa::data_dir(),
tls_config: initial_tls,
upstream_mode: config.upstream.mode,
root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints),
upstream_mode: resolved_mode,
root_hints,
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,
@@ -208,7 +259,6 @@ async fn main() -> numa::Result<()> {
});
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
let api_url = format!("http://localhost:{}", api_port);
let proxy_label = if config.proxy.enabled {
@@ -308,6 +358,17 @@ async fn main() -> numa::Result<()> {
);
if let Some(ref label) = proxy_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.lan.enabled {
row("LAN", g, "mDNS (_numa._tcp.local)");
@@ -375,16 +436,11 @@ async fn main() -> numa::Result<()> {
axum::serve(listener, app).await.unwrap();
});
// Proxy binds 0.0.0.0 when LAN is enabled (cross-machine access), otherwise config value
let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled {
std::net::Ipv4Addr::UNSPECIFIED
} else {
config
.proxy
.bind_addr
.parse()
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
};
let proxy_bind: std::net::Ipv4Addr = config
.proxy
.bind_addr
.parse()
.unwrap_or(std::net::Ipv4Addr::LOCALHOST);
// Spawn HTTP reverse proxy for .numa domains
if config.proxy.enabled {
@@ -425,7 +481,14 @@ async fn main() -> numa::Result<()> {
#[allow(clippy::infinite_loop)]
loop {
let mut buffer = BytePacketBuffer::new();
let (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?;
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 ctx = Arc::clone(&ctx);
tokio::spawn(async move {
@@ -468,7 +531,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(|| "9.9.9.9".to_string());
.unwrap_or_else(|| QUAD9_IP.to_string());
if let Ok(new_sock) =
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
{

View File

@@ -117,6 +117,22 @@ impl OverrideStore {
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 {
self.entries.values().filter(|e| !e.is_expired()).count()
}
@@ -154,3 +170,16 @@ 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,6 +57,34 @@ 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 {
let mut resp = DnsPacket::new();
resp.header.id = query.header.id;
@@ -582,4 +610,16 @@ mod tests {
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,6 +38,21 @@ impl QueryLog {
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> {
self.entries
.iter()
@@ -77,3 +92,25 @@ pub struct QueryLogFilter {
pub since: Option<SystemTime>,
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,6 +136,46 @@ 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) {
match self {
DnsRecord::A { ttl, .. }
@@ -650,4 +690,14 @@ mod tests {
let parsed = round_trip(&rec);
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::header::ResultCode;
use crate::packet::DnsPacket;
use crate::question::{DnsQuestion, QueryType};
use crate::question::QueryType;
use crate::record::DnsRecord;
use crate::srtt::SrttCache;
@@ -32,6 +32,14 @@ fn dns_addr(ip: impl Into<IpAddr>) -> SocketAddr {
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() {
UDP_DISABLED.store(false, Ordering::Release);
UDP_FAILURES.store(0, Ordering::Release);
@@ -46,11 +54,8 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
Some(h) => *h,
None => return,
};
let mut probe = DnsPacket::new();
probe.header.id = next_id();
probe
.questions
.push(DnsQuestion::new(".".to_string(), QueryType::NS));
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
probe.header.recursion_desired = false;
if forward_udp(&probe, hint, Duration::from_millis(1500))
.await
.is_ok()
@@ -60,6 +65,21 @@ 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(
cache: &RwLock<DnsCache>,
root_hints: &[SocketAddr],
@@ -296,17 +316,8 @@ pub(crate) fn resolve_iterative<'a>(
)
.await
{
for rec in &ns_resp.answers {
match rec {
DnsRecord::A { addr, .. } => {
new_ns_addrs.push(dns_addr(*addr));
}
DnsRecord::AAAA { addr, .. } => {
new_ns_addrs.push(dns_addr(*addr));
}
_ => {}
}
}
new_ns_addrs
.extend(ns_resp.answers.iter().filter_map(record_to_addr));
}
if !new_ns_addrs.is_empty() {
break;
@@ -360,13 +371,7 @@ fn find_closest_ns(
if let DnsRecord::NS { host, .. } = ns_rec {
for qt in [QueryType::A, QueryType::AAAA] {
if let Some(resp) = guard.lookup(host, qt) {
for rec in &resp.answers {
match rec {
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
_ => {}
}
}
addrs.extend(resp.answers.iter().filter_map(record_to_addr));
}
}
}
@@ -452,13 +457,7 @@ fn addrs_from_cache(cache: &RwLock<DnsCache>, name: &str) -> Vec<SocketAddr> {
let mut addrs = Vec::new();
for qt in [QueryType::A, QueryType::AAAA] {
if let Some(pkt) = guard.lookup(name, qt) {
for rec in &pkt.answers {
match rec {
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
_ => {}
}
}
addrs.extend(pkt.answers.iter().filter_map(record_to_addr));
}
}
addrs
@@ -468,15 +467,13 @@ fn glue_addrs_for(response: &DnsPacket, ns_name: &str) -> Vec<SocketAddr> {
response
.resources
.iter()
.filter_map(|r| match r {
DnsRecord::A { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
Some(dns_addr(*addr))
.filter(|r| match r {
DnsRecord::A { domain, .. } | DnsRecord::AAAA { domain, .. } => {
domain.eq_ignore_ascii_case(ns_name)
}
DnsRecord::AAAA { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
Some(dns_addr(*addr))
}
_ => None,
_ => false,
})
.filter_map(record_to_addr)
.collect()
}
@@ -596,12 +593,8 @@ async fn send_query(
server: SocketAddr,
srtt: &RwLock<SrttCache>,
) -> crate::Result<DnsPacket> {
let mut query = DnsPacket::new();
query.header.id = next_id();
let mut query = DnsPacket::query(next_id(), qname, qtype);
query.header.recursion_desired = false;
query
.questions
.push(DnsQuestion::new(qname.to_string(), qtype));
query.edns = Some(crate::packet::EdnsOpt {
do_bit: true,
..Default::default()
@@ -1056,11 +1049,7 @@ mod tests {
})
.await;
let mut query = DnsPacket::new();
query.header.id = 0xBEEF;
query
.questions
.push(DnsQuestion::new("test.com".to_string(), QueryType::A));
let query = DnsPacket::query(0xBEEF, "test.com", QueryType::A);
let resp = crate::forward::forward_tcp(&query, server_addr, Duration::from_secs(2))
.await
@@ -1120,11 +1109,7 @@ mod tests {
.unwrap();
});
let mut query = DnsPacket::new();
query.header.id = 0xCAFE;
query
.questions
.push(DnsQuestion::new("strict.test".to_string(), QueryType::A));
let query = DnsPacket::query(0xCAFE, "strict.test", QueryType::A);
let resp = crate::forward::forward_tcp(&query, addr, Duration::from_secs(2))
.await

View File

@@ -47,16 +47,19 @@ impl SrttCache {
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
fn decayed_srtt(entry: &SrttEntry) -> u64 {
let age_secs = entry.updated_at.elapsed().as_secs();
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
}
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
if age_secs > DECAY_AFTER_SECS {
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
let mut srtt = entry.srtt_ms;
let mut srtt = srtt_ms;
for _ in 0..periods {
srtt = (srtt + INITIAL_SRTT_MS) / 2;
}
srtt
} else {
entry.srtt_ms
srtt_ms
}
}
@@ -100,6 +103,14 @@ impl SrttCache {
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 {
self.entries.len()
}
@@ -108,13 +119,6 @@ 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;
@@ -210,63 +214,41 @@ 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() {
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);
// 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 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);
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 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;
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!(cache.get(ip(1)), expected);
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 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)));
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 mut cache = saturated_penalty_cache();
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
let decayed = cache.get(ip(1));
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
assert!(
diff < 25,
@@ -278,29 +260,38 @@ mod tests {
#[test]
fn record_rtt_applies_decay_before_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);
// 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() {
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)]);
// 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
);
}
// 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]
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]

View File

@@ -1,5 +1,92 @@
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 {
queries_total: u64,
queries_forwarded: u64,

View File

@@ -2,6 +2,10 @@ use std::net::SocketAddr;
use log::info;
fn is_loopback_or_stub(addr: &str) -> bool {
matches!(addr, "127.0.0.1" | "127.0.0.53" | "0.0.0.0" | "::1" | "")
}
/// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`.
#[derive(Debug, Clone)]
pub struct ForwardingRule {
@@ -26,10 +30,7 @@ pub fn discover_system_dns() -> SystemDnsInfo {
}
#[cfg(target_os = "linux")]
{
SystemDnsInfo {
default_upstream: detect_upstream_linux_or_backup(),
forwarding_rules: Vec::new(),
}
discover_linux()
}
#[cfg(windows)]
{
@@ -102,11 +103,7 @@ fn discover_macos() -> SystemDnsInfo {
if ns.parse::<std::net::Ipv4Addr>().is_ok() {
current_nameserver = Some(ns.clone());
// Capture first non-supplemental, non-loopback nameserver as default upstream
if !is_supplemental
&& default_upstream.is_none()
&& ns != "127.0.0.1"
&& ns != "0.0.0.0"
{
if !is_supplemental && default_upstream.is_none() && !is_loopback_or_stub(&ns) {
default_upstream = Some(ns);
}
}
@@ -156,7 +153,7 @@ fn discover_macos() -> SystemDnsInfo {
}
}
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?;
Some(ForwardingRule {
@@ -166,38 +163,100 @@ fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
})
}
/// Detect upstream from /etc/resolv.conf, falling back to backup file if resolv.conf
/// only has loopback (meaning numa install already ran).
#[cfg(target_os = "linux")]
fn detect_upstream_linux_or_backup() -> Option<String> {
// Try /etc/resolv.conf first
if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") {
info!("detected system upstream: {}", ns);
return Some(ns);
}
// If resolv.conf only has loopback, check the backup from `numa install`
let backup = {
let home = std::env::var("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
home.join(".numa").join("original-resolv.conf")
};
if let Some(ns) = read_upstream_from_file(backup.to_str().unwrap_or("")) {
info!("detected original upstream from backup: {}", ns);
return Some(ns);
}
None
}
const CLOUD_VPC_RESOLVER: &str = "169.254.169.253";
#[cfg(target_os = "linux")]
fn read_upstream_from_file(path: &str) -> Option<String> {
let text = std::fs::read_to_string(path).ok()?;
fn discover_linux() -> SystemDnsInfo {
// Parse resolv.conf once for both upstream and search domains
let (upstream, search_domains) = parse_resolv_conf("/etc/resolv.conf");
let default_upstream = if let Some(ns) = upstream {
info!("detected system upstream: {}", ns);
Some(ns)
} else {
// Fallback to backup from a previous `numa install`
let backup = {
let home = std::env::var("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
home.join(".numa").join("original-resolv.conf")
};
let (ns, _) = parse_resolv_conf(backup.to_str().unwrap_or(""));
if let Some(ref ns) = ns {
info!("detected original upstream from backup: {}", ns);
}
ns
};
// On cloud VMs (AWS/GCP), internal domains need to reach the VPC resolver
let forwarding_rules = if search_domains.is_empty() {
Vec::new()
} else {
let forwarder = resolvectl_dns_server().unwrap_or_else(|| CLOUD_VPC_RESOLVER.to_string());
let rules: Vec<_> = search_domains
.iter()
.filter_map(|domain| {
let rule = make_rule(domain, &forwarder)?;
info!("forwarding .{} to {}", domain, forwarder);
Some(rule)
})
.collect();
if !rules.is_empty() {
info!("detected {} search domain forwarding rules", rules.len());
}
rules
};
SystemDnsInfo {
default_upstream,
forwarding_rules,
}
}
/// Parse resolv.conf in a single pass, extracting both the first non-loopback
/// nameserver and all search domains.
#[cfg(target_os = "linux")]
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(_) => return (None, Vec::new()),
};
let mut upstream = None;
let mut search_domains = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.starts_with("nameserver") {
if let Some(ns) = line.split_whitespace().nth(1) {
if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" {
return Some(ns.to_string());
if upstream.is_none() {
if let Some(ns) = line.split_whitespace().nth(1) {
if !is_loopback_or_stub(ns) {
upstream = Some(ns.to_string());
}
}
}
} else if line.starts_with("search") || line.starts_with("domain") {
for domain in line.split_whitespace().skip(1) {
search_domains.push(domain.to_string());
}
}
}
(upstream, search_domains)
}
/// Query resolvectl for the real upstream DNS server (e.g. VPC resolver on AWS).
#[cfg(target_os = "linux")]
fn resolvectl_dns_server() -> Option<String> {
let output = std::process::Command::new("resolvectl")
.args(["status", "--no-pager"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
if line.contains("DNS Servers") || line.contains("Current DNS Server") {
if let Some(ip) = line.split(':').next_back() {
let ip = ip.trim();
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
return Some(ip.to_string());
}
}
}
@@ -236,10 +295,7 @@ fn detect_dhcp_dns_macos() -> Option<String> {
// Take the first non-loopback DNS server
for addr in inner.split(',') {
let addr = addr.trim();
if !addr.is_empty()
&& addr != "127.0.0.1"
&& addr != "0.0.0.0"
&& addr.parse::<std::net::Ipv4Addr>().is_ok()
if !is_loopback_or_stub(addr) && addr.parse::<std::net::Ipv4Addr>().is_ok()
{
log::info!("detected DHCP DNS: {}", addr);
return Some(addr.to_string());
@@ -278,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.is_empty() && ip != "127.0.0.1" && ip != "::1" {
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
upstream = Some(ip.to_string());
break;
}
@@ -302,6 +358,339 @@ 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.
@@ -316,43 +705,6 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
// --- System DNS configuration (install/uninstall) ---
/// Set the system DNS to 127.0.0.1 so all queries go through Numa.
/// Saves the original DNS settings for later restoration.
pub fn install_system_dns() -> Result<(), String> {
#[cfg(target_os = "macos")]
let result = install_macos();
#[cfg(target_os = "linux")]
let result = install_linux();
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
let result = Err("system DNS configuration not supported on this OS".to_string());
if result.is_ok() {
if let Err(e) = trust_ca() {
eprintln!(" warning: could not trust CA: {}", e);
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
}
}
result
}
/// Restore the original system DNS settings saved during install.
pub fn uninstall_system_dns() -> Result<(), String> {
let _ = untrust_ca();
#[cfg(target_os = "macos")]
{
uninstall_macos()
}
#[cfg(target_os = "linux")]
{
uninstall_linux()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
Err("system DNS configuration not supported on this OS".to_string())
}
}
// --- macOS implementation ---
#[cfg(target_os = "macos")]
@@ -500,21 +852,27 @@ const SYSTEMD_UNIT: &str = "/etc/systemd/system/numa.service";
/// Install Numa as a system service that starts on boot and auto-restarts.
pub fn install_service() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
install_service_macos()
}
let result = install_service_macos();
#[cfg(target_os = "linux")]
{
install_service_linux()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
Err("service installation not supported on this OS".to_string())
let result = install_service_linux();
#[cfg(windows)]
let result = install_windows();
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
let result = Err::<(), String>("service installation not supported on this OS".to_string());
if result.is_ok() {
if let Err(e) = trust_ca() {
eprintln!(" warning: could not trust CA: {}", e);
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
}
}
result
}
/// Uninstall the Numa system service.
pub fn uninstall_service() -> Result<(), String> {
let _ = untrust_ca();
#[cfg(target_os = "macos")]
{
uninstall_service_macos()
@@ -523,7 +881,11 @@ pub fn uninstall_service() -> Result<(), String> {
{
uninstall_service_linux()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
#[cfg(windows)]
{
uninstall_windows()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
{
Err("service uninstallation not supported on this OS".to_string())
}
@@ -609,7 +971,7 @@ fn install_service_macos() -> Result<(), String> {
std::fs::write(PLIST_DEST, plist)
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?;
// Load the service
// Load the service first so numa is listening before DNS redirect
let status = std::process::Command::new("launchctl")
.args(["load", "-w", PLIST_DEST])
.status()
@@ -619,14 +981,34 @@ fn install_service_macos() -> Result<(), String> {
return Err("launchctl load failed".to_string());
}
// Set system DNS to 127.0.0.1 now that the service is running
eprintln!(" Service installed and started.");
// Wait for numa to be ready before redirecting DNS
let api_up = (0..10).any(|i| {
if i > 0 {
std::thread::sleep(std::time::Duration::from_millis(500));
}
std::net::TcpStream::connect(("127.0.0.1", crate::config::DEFAULT_API_PORT)).is_ok()
});
if !api_up {
// Service failed to start — don't redirect DNS to a dead endpoint
let _ = std::process::Command::new("launchctl")
.args(["unload", PLIST_DEST])
.status();
return Err(
"numa service did not start (port 53 may be in use). Service unloaded.".to_string(),
);
}
if let Err(e) = install_macos() {
eprintln!(" warning: failed to configure system DNS: {}", e);
}
eprintln!(" Service installed and started.");
eprintln!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: /usr/local/var/log/numa.log");
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
Ok(())
}
@@ -708,8 +1090,11 @@ fn install_linux() -> Result<(), String> {
.map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?;
let drop_in = resolved_dir.join("numa.conf");
std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n")
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
std::fs::write(
&drop_in,
"[Resolve]\nDNS=127.0.0.1\nDomains=~.\nDNSStubListener=no\n",
)
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
let _ = run_systemctl(&["restart", "systemd-resolved"]);
eprintln!(" systemd-resolved detected.");
@@ -802,17 +1187,21 @@ fn install_service_linux() -> Result<(), String> {
run_systemctl(&["daemon-reload"])?;
run_systemctl(&["enable", "numa"])?;
run_systemctl(&["start", "numa"])?;
eprintln!(" Service installed and started.");
// Set system DNS now that the service is running
// Configure system DNS before starting numa so resolved releases port 53 first
if let Err(e) = install_linux() {
eprintln!(" warning: failed to configure system DNS: {}", e);
}
run_systemctl(&["start", "numa"])?;
eprintln!(" Service installed and started.");
eprintln!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: journalctl -u numa -f");
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
eprintln!(" Run 'sudo numa uninstall' to restore original DNS.\n");
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
eprintln!(" [upstream]");
eprintln!(" mode = \"recursive\"\n");
Ok(())
}
@@ -977,3 +1366,57 @@ 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"));
}
}