11 Commits

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

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

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

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

* fix: use phys_footprint on macOS to match Activity Monitor

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

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

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

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

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

---------

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

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

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

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

* feat: enable DNSSEC validation by default

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

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

* feat: forward search domains to VPC resolver on Linux

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

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

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

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

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

* fix: macOS install health check, harden recursive probe

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

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

* fix: widen make_rule cfg gate to include Linux

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

* docs: update proxy bind_addr comment in example config

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-29 23:15:42 +03:00
Razvan Dimescu
32cd8624b4 refactor: deduplicate query builders, record extraction, sinkhole records (#22)
- Add DnsPacket::query(id, domain, qtype) constructor; replace mock_query,
  make_query, and 4 inline constructions across ctx/forward/recursive/api
- Add record_to_addr() in recursive.rs; replace 4 identical A/AAAA match
  blocks with filter_map one-liners
- Add sinkhole_record() in ctx.rs; consolidate localhost and blocklist
  A/AAAA branching into single calls
- Remove now-unused DnsQuestion imports

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

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

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

* fix: SERVFAIL echoes question section, preserve error messages

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

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 11:14:25 +03:00
23 changed files with 1813 additions and 637 deletions

2
Cargo.lock generated
View File

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

View File

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

189
README.md
View File

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

View File

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

View File

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

View File

@@ -54,7 +54,7 @@ enabled = true
port = 80 port = 80
tls_port = 443 tls_port = 443
tld = "numa" tld = "numa"
# bind_addr = "127.0.0.1" # default; 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) # Pre-configured services (numa.numa is always added automatically)
# [[services]] # [[services]]

View File

@@ -101,7 +101,7 @@ body {
/* Stat cards row */ /* Stat cards row */
.stats-row { .stats-row {
display: grid; display: grid;
grid-template-columns: repeat(5, 1fr); grid-template-columns: repeat(6, 1fr);
gap: 1rem; gap: 1rem;
} }
.stat-card { .stat-card {
@@ -125,6 +125,8 @@ body {
.stat-card.blocked::before { background: var(--rose); } .stat-card.blocked::before { background: var(--rose); }
.stat-card.overrides::before { background: var(--violet); } .stat-card.overrides::before { background: var(--violet); }
.stat-card.uptime::before { background: var(--cyan); } .stat-card.uptime::before { background: var(--cyan); }
.stat-card.memory::before { background: var(--text-dim); }
.stat-card.memory .stat-value { color: var(--text-secondary); }
.stat-label { .stat-label {
font-size: 0.7rem; font-size: 0.7rem;
@@ -468,10 +470,74 @@ body {
display: none; display: none;
} }
/* Memory sidebar panel */
.memory-bar {
display: flex;
height: 18px;
border-radius: 4px;
overflow: hidden;
background: var(--bg-surface);
margin-bottom: 0.8rem;
}
.memory-bar-seg {
height: 100%;
min-width: 2px;
transition: width 0.6s ease;
}
.memory-bar-seg.cache { background: var(--teal); }
.memory-bar-seg.blocklist { background: var(--rose); }
.memory-bar-seg.querylog { background: var(--amber); }
.memory-bar-seg.srtt { background: var(--cyan); }
.memory-bar-seg.overrides { background: var(--violet); }
.memory-row {
display: flex;
align-items: center;
padding: 0.3rem 0;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 0.72rem;
}
.memory-row:last-child { border-bottom: none; }
.memory-row-dot {
width: 8px;
height: 8px;
border-radius: 2px;
flex-shrink: 0;
margin-right: 0.5rem;
}
.memory-row-label {
flex: 1;
color: var(--text-secondary);
}
.memory-row-size {
width: 65px;
text-align: right;
color: var(--text-primary);
font-weight: 500;
}
.memory-row-entries {
width: 90px;
text-align: right;
color: var(--text-dim);
}
.memory-rss {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--border);
display: flex;
justify-content: space-between;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
/* Responsive */ /* Responsive */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.main-grid { grid-template-columns: 1fr; } .main-grid { grid-template-columns: 1fr; }
} }
@media (max-width: 900px) {
.stats-row { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) { @media (max-width: 700px) {
.stats-row { grid-template-columns: repeat(2, 1fr); } .stats-row { grid-template-columns: repeat(2, 1fr); }
.dashboard { padding: 1rem; } .dashboard { padding: 1rem; }
@@ -524,6 +590,11 @@ body {
<div class="stat-value" id="uptime"></div> <div class="stat-value" id="uptime"></div>
<div class="stat-sub" id="uptimeSub">&nbsp;</div> <div class="stat-sub" id="uptimeSub">&nbsp;</div>
</div> </div>
<div class="stat-card memory">
<div class="stat-label">Memory</div>
<div class="stat-value" id="memoryRss"></div>
<div class="stat-sub" id="memorySub">&nbsp;</div>
</div>
</div> </div>
<!-- Resolution paths --> <!-- Resolution paths -->
@@ -648,6 +719,17 @@ body {
</div> </div>
</div> </div>
<!-- Memory breakdown -->
<div class="panel" id="memoryPanel">
<div class="panel-header">
<span class="panel-title">Memory</span>
<span class="panel-title" id="memoryTotal" style="color: var(--text-dim)"></span>
</div>
<div class="panel-body" id="memoryBody">
<div class="empty-state">No memory data</div>
</div>
</div>
<!-- Cache entries --> <!-- Cache entries -->
<div class="panel"> <div class="panel">
<div class="panel-header"> <div class="panel-header">
@@ -712,6 +794,69 @@ function formatRemaining(secs) {
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`; return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`;
} }
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
return (bytes / 1073741824).toFixed(1) + ' GB';
}
const MEMORY_COMPONENTS = [
{ key: 'cache', label: 'Cache', cls: 'cache', color: 'var(--teal)' },
{ key: 'blocklist', label: 'Blocklist', cls: 'blocklist', color: 'var(--rose)' },
{ key: 'query_log', label: 'Query Log', cls: 'querylog', color: 'var(--amber)' },
{ key: 'srtt', label: 'SRTT', cls: 'srtt', color: 'var(--cyan)' },
{ key: 'overrides', label: 'Overrides', cls: 'overrides', color: 'var(--violet)' },
];
function renderMemory(mem, stats) {
if (!mem) return;
// Stat card
document.getElementById('memoryRss').textContent = formatBytes(mem.process_memory_bytes);
document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes);
const entryCounts = {
cache: stats.cache.entries,
blocklist: stats.blocking.domains_loaded,
query_log: mem.query_log_entries,
srtt: mem.srtt_entries,
overrides: stats.overrides.active,
};
// Sidebar panel
const total = mem.total_estimated_bytes || 1;
document.getElementById('memoryTotal').textContent = formatBytes(total);
const barSegments = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const pct = ((bytes / total) * 100).toFixed(1);
return `<div class="memory-bar-seg ${c.cls}" style="width:${pct}%" title="${c.label}: ${formatBytes(bytes)} (${pct}%)"></div>`;
}).join('');
const rows = MEMORY_COMPONENTS.map(c => {
const bytes = mem[c.key + '_bytes'] || 0;
const entries = entryCounts[c.key] || 0;
return `
<div class="memory-row">
<div class="memory-row-dot" style="background:${c.color}"></div>
<span class="memory-row-label">${c.label}</span>
<span class="memory-row-size">${formatBytes(bytes)}</span>
<span class="memory-row-entries">${formatNumber(entries)} entries</span>
</div>`;
}).join('');
document.getElementById('memoryBody').innerHTML = `
<div class="memory-bar">${barSegments}</div>
${rows}
<div class="memory-rss">
<span>Process Footprint</span>
<span>${formatBytes(mem.process_memory_bytes)}</span>
</div>
`;
}
const PATH_DEFS = [ const PATH_DEFS = [
{ key: 'forwarded', label: 'Forward', cls: 'forward' }, { key: 'forwarded', label: 'Forward', cls: 'forward' },
{ key: 'recursive', label: 'Recursive', cls: 'recursive' }, { key: 'recursive', label: 'Recursive', cls: 'recursive' },
@@ -882,6 +1027,9 @@ async function refresh() {
document.getElementById('footerUpstream').textContent = stats.upstream || ''; document.getElementById('footerUpstream').textContent = stats.upstream || '';
document.getElementById('footerConfig').textContent = stats.config_path || ''; document.getElementById('footerConfig').textContent = stats.config_path || '';
document.getElementById('footerData').textContent = stats.data_dir || ''; document.getElementById('footerData').textContent = stats.data_dir || '';
const modeEl = document.getElementById('footerMode');
modeEl.textContent = stats.mode || '—';
modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)';
document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off'; document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off';
document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)'; document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off'; document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
@@ -945,7 +1093,7 @@ async function refresh() {
prevTime = now; prevTime = now;
// Cache hit rate // 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'; const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0';
document.getElementById('cacheRate').textContent = hitRate + '%'; document.getElementById('cacheRate').textContent = hitRate + '%';
@@ -957,6 +1105,7 @@ async function refresh() {
renderServices(services); renderServices(services);
renderBlockingInfo(blockingInfo); renderBlockingInfo(blockingInfo);
renderAllowlist(allowlist); renderAllowlist(allowlist);
renderMemory(stats.memory, stats);
} catch (err) { } catch (err) {
document.getElementById('statusDot').className = 'status-dot error'; document.getElementById('statusDot').className = 'status-dot error';
@@ -1236,6 +1385,7 @@ setInterval(refresh, 2000);
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span> Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span> · Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span> · Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
· Mode: <span id="footerMode" style="color:var(--text-dim);"></span>
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span> · DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);"></span>
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span> · SRTT: <span id="footerSrtt" style="color:var(--text-dim);"></span>
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span> · Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>

View File

@@ -4,10 +4,10 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Numa — DNS you own. Everywhere you go.</title> <title>Numa — DNS you own. Everywhere you go.</title>
<meta name="description" content="DNS you own. 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"> <link rel="canonical" href="https://numa.rs">
<meta property="og:title" content="Numa — DNS you own. Everywhere you go."> <meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
<meta property="og:description" content="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:type" content="website">
<meta property="og:url" content="https://numa.rs"> <meta property="og:url" content="https://numa.rs">
<link rel="stylesheet" href="/fonts/fonts.css"> <link rel="stylesheet" href="/fonts/fonts.css">
@@ -1232,17 +1232,17 @@ footer .closing {
<div class="reveal"> <div class="reveal">
<div class="section-label">How It Works</div> <div class="section-label">How It Works</div>
<h2>What it does today</h2> <h2>What it does today</h2>
<p class="lead">A 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>
<div class="layers-grid"> <div class="layers-grid">
<div class="layer-card reveal reveal-delay-1"> <div class="layer-card reveal reveal-delay-1">
<div class="layer-badge">Layer 1</div> <div class="layer-badge">Layer 1</div>
<h3>Resolve &amp; Protect</h3> <h3>Resolve &amp; Protect</h3>
<ul> <ul>
<li>Recursive resolution &mdash; resolve from root nameservers, no upstream needed</li> <li>Forward mode by default &mdash; transparent proxy to your existing DNS, with caching</li>
<li>DNSSEC validation &mdash; chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
<li>Ad &amp; tracker blocking &mdash; 385K+ domains, zero config</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>TTL-aware caching (sub-ms lookups)</li>
<li>Single binary, portable &mdash; macOS, Linux, and Windows</li> <li>Single binary, portable &mdash; macOS, Linux, and Windows</li>
</ul> </ul>

View File

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

View File

@@ -183,6 +183,15 @@ impl BlocklistStore {
self.allowlist.iter().cloned().collect() self.allowlist.iter().cloned().collect()
} }
pub fn heap_bytes(&self) -> usize {
let per_slot_overhead = std::mem::size_of::<u64>() + std::mem::size_of::<String>() + 1;
let domains_table = self.domains.capacity() * per_slot_overhead;
let domains_heap: usize = self.domains.iter().map(|d| d.capacity()).sum();
let allow_table = self.allowlist.capacity() * per_slot_overhead;
let allow_heap: usize = self.allowlist.iter().map(|d| d.capacity()).sum();
domains_table + domains_heap + allow_table + allow_heap
}
pub fn stats(&self) -> BlocklistStats { pub fn stats(&self) -> BlocklistStats {
BlocklistStats { BlocklistStats {
enabled: self.is_enabled(), enabled: self.is_enabled(),
@@ -234,6 +243,23 @@ pub fn parse_blocklist(text: &str) -> HashSet<String> {
domains 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)> { pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))

View File

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

View File

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

View File

@@ -93,18 +93,13 @@ pub async fn handle_query(
} else if qname == "localhost" || qname.ends_with(".localhost") { } else if qname == "localhost" || qname.ends_with(".localhost") {
// RFC 6761: .localhost always resolves to loopback // RFC 6761: .localhost always resolves to loopback
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
match qtype { resp.answers.push(sinkhole_record(
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { &qname,
domain: qname.clone(), qtype,
addr: std::net::Ipv6Addr::LOCALHOST, std::net::Ipv4Addr::LOCALHOST,
ttl: 300, std::net::Ipv6Addr::LOCALHOST,
}), 300,
_ => resp.answers.push(DnsRecord::A { ));
domain: qname.clone(),
addr: std::net::Ipv4Addr::LOCALHOST,
ttl: 300,
}),
}
(resp, QueryPath::Local, DnssecStatus::Indeterminate) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if is_special_use_domain(&qname) { } else if is_special_use_domain(&qname) {
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally // RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
@@ -113,12 +108,17 @@ pub async fn handle_query(
} else if !ctx.proxy_tld_suffix.is_empty() } else if !ctx.proxy_tld_suffix.is_empty()
&& (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld)
{ {
// Resolve .numa: 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 service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname);
let is_remote = !src_addr.ip().is_loopback();
let resolve_ip = { let resolve_ip = {
let local = ctx.services.lock().unwrap(); let local = ctx.services.lock().unwrap();
if local.lookup(service_name).is_some() { if local.lookup(service_name).is_some() {
std::net::Ipv4Addr::LOCALHOST if is_remote {
*ctx.lan_ip.lock().unwrap()
} else {
std::net::Ipv4Addr::LOCALHOST
}
} else { } else {
let mut peers = ctx.lan_peers.lock().unwrap(); let mut peers = ctx.lan_peers.lock().unwrap();
peers peers
@@ -130,38 +130,24 @@ pub async fn handle_query(
.unwrap_or(std::net::Ipv4Addr::LOCALHOST) .unwrap_or(std::net::Ipv4Addr::LOCALHOST)
} }
}; };
let v6 = if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
std::net::Ipv6Addr::LOCALHOST
} else {
resolve_ip.to_ipv6_mapped()
};
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
match qtype { resp.answers
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { .push(sinkhole_record(&qname, qtype, resolve_ip, v6, 300));
domain: qname.clone(),
addr: if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
std::net::Ipv6Addr::LOCALHOST
} else {
resolve_ip.to_ipv6_mapped()
},
ttl: 300,
}),
_ => resp.answers.push(DnsRecord::A {
domain: qname.clone(),
addr: resolve_ip,
ttl: 300,
}),
}
(resp, QueryPath::Local, DnssecStatus::Indeterminate) (resp, QueryPath::Local, DnssecStatus::Indeterminate)
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) { } else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
match qtype { resp.answers.push(sinkhole_record(
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { &qname,
domain: qname.clone(), qtype,
addr: std::net::Ipv6Addr::UNSPECIFIED, std::net::Ipv4Addr::UNSPECIFIED,
ttl: 60, std::net::Ipv6Addr::UNSPECIFIED,
}), 60,
_ => resp.answers.push(DnsRecord::A { ));
domain: qname.clone(),
addr: std::net::Ipv4Addr::UNSPECIFIED,
ttl: 60,
}),
}
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate) (resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) { } else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
@@ -178,62 +164,29 @@ pub async fn handle_query(
(resp, QueryPath::Cached, cached_dnssec) (resp, QueryPath::Cached, cached_dnssec)
} else if ctx.upstream_mode == UpstreamMode::Recursive { } else if ctx.upstream_mode == UpstreamMode::Recursive {
let key = (qname.clone(), qtype); let key = (qname.clone(), qtype);
let disposition = acquire_inflight(&ctx.inflight, key.clone()); let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
crate::recursive::resolve_recursive(
match disposition { &qname,
Disposition::Follower(mut rx) => { qtype,
debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname); &ctx.cache,
match rx.recv().await { &query,
Ok(Some(mut resp)) => { &ctx.root_hints,
resp.header.id = query.header.id; &ctx.srtt,
(resp, QueryPath::Coalesced, DnssecStatus::Indeterminate) )
} })
_ => ( .await;
DnsPacket::response_from(&query, ResultCode::SERVFAIL), if path == QueryPath::Coalesced {
QueryPath::UpstreamError, debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname);
DnssecStatus::Indeterminate, } else if path == QueryPath::UpstreamError {
), error!(
} "{} | {:?} {} | RECURSIVE ERROR | {}",
} src_addr,
Disposition::Leader(tx) => { qtype,
// Drop guard: remove inflight entry even on panic/cancellation qname,
let guard = InflightGuard { err.as_deref().unwrap_or("leader failed")
inflight: &ctx.inflight, );
key: key.clone(),
};
let result = crate::recursive::resolve_recursive(
&qname,
qtype,
&ctx.cache,
&query,
&ctx.root_hints,
&ctx.srtt,
)
.await;
drop(guard);
match result {
Ok(resp) => {
let _ = tx.send(Some(resp.clone()));
(resp, QueryPath::Recursive, DnssecStatus::Indeterminate)
}
Err(e) => {
let _ = tx.send(None);
error!(
"{} | {:?} {} | RECURSIVE ERROR | {}",
src_addr, qtype, qname, e
);
(
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
DnssecStatus::Indeterminate,
)
}
}
}
} }
(resp, path, DnssecStatus::Indeterminate)
} else { } else {
let upstream = let upstream =
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) { match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
@@ -416,6 +369,27 @@ fn is_special_use_domain(qname: &str) -> bool {
qname == "local" || qname.ends_with(".local") qname == "local" || qname.ends_with(".local")
} }
fn sinkhole_record(
domain: &str,
qtype: QueryType,
v4: std::net::Ipv4Addr,
v6: std::net::Ipv6Addr,
ttl: u32,
) -> DnsRecord {
match qtype {
QueryType::AAAA => DnsRecord::AAAA {
domain: domain.to_string(),
addr: v6,
ttl,
},
_ => DnsRecord::A {
domain: domain.to_string(),
addr: v4,
ttl,
},
}
}
enum Disposition { enum Disposition {
Leader(broadcast::Sender<Option<DnsPacket>>), Leader(broadcast::Sender<Option<DnsPacket>>),
Follower(broadcast::Receiver<Option<DnsPacket>>), Follower(broadcast::Receiver<Option<DnsPacket>>),
@@ -432,6 +406,57 @@ fn acquire_inflight(inflight: &Mutex<InflightMap>, key: (String, QueryType)) ->
} }
} }
/// Run a resolve function with in-flight coalescing. Multiple concurrent calls
/// for the same key share a single resolution — the first caller (leader)
/// executes `resolve_fn`, and followers wait for the broadcast result.
async fn resolve_coalesced<F, Fut>(
inflight: &Mutex<InflightMap>,
key: (String, QueryType),
query: &DnsPacket,
resolve_fn: F,
) -> (DnsPacket, QueryPath, Option<String>)
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = crate::Result<DnsPacket>>,
{
let disposition = acquire_inflight(inflight, key.clone());
match disposition {
Disposition::Follower(mut rx) => match rx.recv().await {
Ok(Some(mut resp)) => {
resp.header.id = query.header.id;
(resp, QueryPath::Coalesced, None)
}
_ => (
DnsPacket::response_from(query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
None,
),
},
Disposition::Leader(tx) => {
let guard = InflightGuard { inflight, key };
let result = resolve_fn().await;
drop(guard);
match result {
Ok(resp) => {
let _ = tx.send(Some(resp.clone()));
(resp, QueryPath::Recursive, None)
}
Err(e) => {
let _ = tx.send(None);
let err_msg = e.to_string();
(
DnsPacket::response_from(query, ResultCode::SERVFAIL),
QueryPath::UpstreamError,
Some(err_msg),
)
}
}
}
}
}
struct InflightGuard<'a> { struct InflightGuard<'a> {
inflight: &'a Mutex<InflightMap>, inflight: &'a Mutex<InflightMap>,
key: (String, QueryType), key: (String, QueryType),
@@ -443,20 +468,6 @@ impl Drop for InflightGuard<'_> {
} }
} }
/// Build a wire-format DNS query packet for the given domain and type.
#[cfg(test)]
fn build_wire_query(id: u16, domain: &str, qtype: QueryType) -> BytePacketBuffer {
let mut pkt = DnsPacket::new();
pkt.header.id = id;
pkt.header.recursion_desired = true;
pkt.header.questions = 1;
pkt.questions
.push(crate::question::DnsQuestion::new(domain.to_string(), qtype));
let mut buf = BytePacketBuffer::new();
pkt.write(&mut buf).unwrap();
BytePacketBuffer::from_bytes(buf.filled())
}
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket { fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
use std::net::{Ipv4Addr, Ipv6Addr}; use std::net::{Ipv4Addr, Ipv6Addr};
if qname == "ipv4only.arpa" { if qname == "ipv4only.arpa" {
@@ -495,8 +506,8 @@ fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> Dns
mod tests { mod tests {
use super::*; use super::*;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr}; use std::net::Ipv4Addr;
use std::sync::{Arc, Mutex, RwLock}; use std::sync::{Arc, Mutex};
use tokio::sync::broadcast; use tokio::sync::broadcast;
// ---- InflightGuard unit tests ---- // ---- InflightGuard unit tests ----
@@ -669,189 +680,212 @@ mod tests {
} }
} }
// ---- Integration: concurrent handle_query coalescing ---- // ---- Integration: resolve_coalesced with mock futures ----
use tokio::io::{AsyncReadExt, AsyncWriteExt}; fn mock_response(domain: &str) -> DnsPacket {
use tokio::net::TcpListener; let mut resp = DnsPacket::new();
resp.header.response = true;
/// Spawn a slow TCP DNS server that delays `delay` before responding. resp.header.rescode = ResultCode::NOERROR;
/// Returns (addr, query_count) where query_count is an Arc<AtomicU32> resp.answers.push(DnsRecord::A {
/// tracking how many queries were actually resolved (not coalesced). domain: domain.to_string(),
async fn spawn_slow_dns_server( addr: Ipv4Addr::new(10, 0, 0, 1),
delay: Duration, ttl: 300,
) -> (SocketAddr, Arc<std::sync::atomic::AtomicU32>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let count_clone = count.clone();
tokio::spawn(async move {
loop {
let (mut stream, _) = match listener.accept().await {
Ok(c) => c,
Err(_) => break,
};
let count = count_clone.clone();
let delay = delay;
tokio::spawn(async move {
let mut len_buf = [0u8; 2];
if stream.read_exact(&mut len_buf).await.is_err() {
return;
}
let len = u16::from_be_bytes(len_buf) as usize;
let mut data = vec![0u8; len];
if stream.read_exact(&mut data).await.is_err() {
return;
}
let mut buf = BytePacketBuffer::from_bytes(&data);
let query = match DnsPacket::from_buffer(&mut buf) {
Ok(q) => q,
Err(_) => return,
};
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
// Deliberate delay to create coalescing window
tokio::time::sleep(delay).await;
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
resp.header.authoritative_answer = true;
if let Some(q) = query.questions.first() {
resp.answers.push(DnsRecord::A {
domain: q.name.clone(),
addr: Ipv4Addr::new(10, 0, 0, 1),
ttl: 300,
});
}
let mut resp_buf = BytePacketBuffer::new();
if resp.write(&mut resp_buf).is_err() {
return;
}
let resp_bytes = resp_buf.filled();
let mut out = Vec::with_capacity(2 + resp_bytes.len());
out.extend_from_slice(&(resp_bytes.len() as u16).to_be_bytes());
out.extend_from_slice(resp_bytes);
let _ = stream.write_all(&out).await;
});
}
}); });
(addr, count) resp
}
async fn test_recursive_ctx(root_hint: SocketAddr) -> Arc<ServerCtx> {
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
Arc::new(ServerCtx {
socket,
zone_map: HashMap::new(),
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
stats: Mutex::new(crate::stats::ServerStats::new()),
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
services: Mutex::new(crate::service_store::ServiceStore::new()),
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
forwarding_rules: Vec::new(),
upstream: Mutex::new(crate::forward::Upstream::Udp(
"127.0.0.1:53".parse().unwrap(),
)),
upstream_auto: false,
upstream_port: 53,
lan_ip: Mutex::new(Ipv4Addr::LOCALHOST),
timeout: Duration::from_secs(3),
proxy_tld: "numa".to_string(),
proxy_tld_suffix: ".numa".to_string(),
lan_enabled: false,
config_path: "/tmp/test-numa.toml".to_string(),
config_found: false,
config_dir: std::path::PathBuf::from("/tmp"),
data_dir: std::path::PathBuf::from("/tmp"),
tls_config: None,
upstream_mode: crate::config::UpstreamMode::Recursive,
root_hints: vec![root_hint],
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
inflight: Mutex::new(HashMap::new()),
dnssec_enabled: false,
dnssec_strict: false,
})
} }
#[tokio::test] #[tokio::test]
async fn concurrent_queries_coalesce_to_single_resolution() { async fn concurrent_queries_coalesce_to_single_resolution() {
// Force TCP-only so mock server works let inflight = Arc::new(Mutex::new(HashMap::new()));
crate::recursive::UDP_DISABLED.store(true, std::sync::atomic::Ordering::Release); let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let (server_addr, query_count) = spawn_slow_dns_server(Duration::from_millis(200)).await;
let ctx = test_recursive_ctx(server_addr).await;
let src: SocketAddr = "127.0.0.1:9999".parse().unwrap();
// Fire 5 concurrent queries for the same (domain, A)
let mut handles = Vec::new(); let mut handles = Vec::new();
for i in 0..5u16 { for i in 0..5u16 {
let ctx = ctx.clone(); let count = resolve_count.clone();
let buf = build_wire_query(100 + i, "coalesce-test.example.com", QueryType::A); let inf = inflight.clone();
handles.push(tokio::spawn( let key = ("coalesce.test".to_string(), QueryType::A);
async move { handle_query(buf, src, &ctx).await }, let query = DnsPacket::query(100 + i, "coalesce.test", QueryType::A);
)); handles.push(tokio::spawn(async move {
resolve_coalesced(&inf, key, &query, || async {
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(200)).await;
Ok(mock_response("coalesce.test"))
})
.await
}));
} }
let mut paths = Vec::new();
for h in handles { for h in handles {
h.await.unwrap().unwrap(); let (_, path, _) = h.await.unwrap();
paths.push(path);
} }
// Only 1 resolution should have reached the upstream server let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
let actual = query_count.load(std::sync::atomic::Ordering::Relaxed); assert_eq!(actual, 1, "expected 1 resolution, got {}", actual);
assert_eq!(actual, 1, "expected 1 upstream query, got {}", actual);
// Inflight map must be empty after all queries complete let recursive = paths.iter().filter(|p| **p == QueryPath::Recursive).count();
assert!(ctx.inflight.lock().unwrap().is_empty()); let coalesced = paths.iter().filter(|p| **p == QueryPath::Coalesced).count();
assert_eq!(recursive, 1, "expected 1 RECURSIVE, got {}", recursive);
assert_eq!(coalesced, 4, "expected 4 COALESCED, got {}", coalesced);
crate::recursive::reset_udp_state(); assert!(inflight.lock().unwrap().is_empty());
} }
#[tokio::test] #[tokio::test]
async fn different_qtypes_not_coalesced() { async fn different_qtypes_not_coalesced() {
crate::recursive::UDP_DISABLED.store(true, std::sync::atomic::Ordering::Release); let inflight = Arc::new(Mutex::new(HashMap::new()));
let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
let (server_addr, query_count) = spawn_slow_dns_server(Duration::from_millis(100)).await; let inf1 = inflight.clone();
let ctx = test_recursive_ctx(server_addr).await; let inf2 = inflight.clone();
let src: SocketAddr = "127.0.0.1:9999".parse().unwrap(); let count1 = resolve_count.clone();
let count2 = resolve_count.clone();
// Fire A and AAAA concurrently — should NOT coalesce let query_a = DnsPacket::query(200, "same.domain", QueryType::A);
let ctx_ref = ctx.clone(); let query_aaaa = DnsPacket::query(201, "same.domain", QueryType::AAAA);
let ctx_ref2 = ctx.clone();
let buf_a = build_wire_query(200, "different-qt.example.com", QueryType::A);
let buf_aaaa = build_wire_query(201, "different-qt.example.com", QueryType::AAAA);
let h1 = tokio::spawn(async move { handle_query(buf_a, src, &ctx_ref).await }); let h1 = tokio::spawn(async move {
let h2 = tokio::spawn(async move { handle_query(buf_aaaa, src, &ctx_ref2).await }); resolve_coalesced(
&inf1,
("same.domain".to_string(), QueryType::A),
&query_a,
|| async {
count1.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(mock_response("same.domain"))
},
)
.await
});
let h2 = tokio::spawn(async move {
resolve_coalesced(
&inf2,
("same.domain".to_string(), QueryType::AAAA),
&query_aaaa,
|| async {
count2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
tokio::time::sleep(Duration::from_millis(100)).await;
Ok(mock_response("same.domain"))
},
)
.await
});
h1.await.unwrap().unwrap(); let (_, path1, _) = h1.await.unwrap();
h2.await.unwrap().unwrap(); let (_, path2, _) = h2.await.unwrap();
let actual = query_count.load(std::sync::atomic::Ordering::Relaxed); let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
assert!( assert_eq!(actual, 2, "A and AAAA should each resolve, got {}", actual);
actual >= 2, assert_eq!(path1, QueryPath::Recursive);
"A and AAAA should resolve independently, got {}", assert_eq!(path2, QueryPath::Recursive);
actual
);
assert!(ctx.inflight.lock().unwrap().is_empty());
crate::recursive::reset_udp_state(); assert!(inflight.lock().unwrap().is_empty());
} }
#[tokio::test] #[tokio::test]
async fn inflight_map_cleaned_after_upstream_error() { async fn inflight_map_cleaned_after_error() {
// Server that rejects everything — no server running at all let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let bogus_addr: SocketAddr = "127.0.0.1:1".parse().unwrap(); let query = DnsPacket::query(300, "will-fail.test", QueryType::A);
let ctx = test_recursive_ctx(bogus_addr).await;
let src: SocketAddr = "127.0.0.1:9999".parse().unwrap();
let buf = build_wire_query(300, "will-fail.example.com", QueryType::A); let (_, path, _) = resolve_coalesced(
let _ = handle_query(buf, src, &ctx).await; &inflight,
("will-fail.test".to_string(), QueryType::A),
&query,
|| async { Err::<DnsPacket, _>("upstream timeout".into()) },
)
.await;
// Map must be clean even after error assert_eq!(path, QueryPath::UpstreamError);
assert!(ctx.inflight.lock().unwrap().is_empty()); assert!(inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn follower_gets_servfail_when_leader_fails() {
let inflight = Arc::new(Mutex::new(HashMap::new()));
let mut handles = Vec::new();
for i in 0..3u16 {
let inf = inflight.clone();
let query = DnsPacket::query(400 + i, "fail.test", QueryType::A);
handles.push(tokio::spawn(async move {
resolve_coalesced(
&inf,
("fail.test".to_string(), QueryType::A),
&query,
|| async {
tokio::time::sleep(Duration::from_millis(200)).await;
Err::<DnsPacket, _>("upstream error".into())
},
)
.await
}));
}
let mut paths = Vec::new();
for h in handles {
let (resp, path, _) = h.await.unwrap();
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
assert_eq!(
resp.questions.len(),
1,
"SERVFAIL must echo question section"
);
assert_eq!(resp.questions[0].name, "fail.test");
paths.push(path);
}
let errors = paths
.iter()
.filter(|p| **p == QueryPath::UpstreamError)
.count();
assert_eq!(errors, 3, "all 3 should be UpstreamError, got {}", errors);
assert!(inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn servfail_leader_includes_question_section() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = DnsPacket::query(500, "question.test", QueryType::A);
let (resp, _, _) = resolve_coalesced(
&inflight,
("question.test".to_string(), QueryType::A),
&query,
|| async { Err::<DnsPacket, _>("fail".into()) },
)
.await;
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
assert_eq!(
resp.questions.len(),
1,
"SERVFAIL must echo question section"
);
assert_eq!(resp.questions[0].name, "question.test");
assert_eq!(resp.questions[0].qtype, QueryType::A);
assert_eq!(resp.header.id, 500);
}
#[tokio::test]
async fn leader_error_preserves_message() {
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
let query = DnsPacket::query(700, "err-msg.test", QueryType::A);
let (_, path, err) = resolve_coalesced(
&inflight,
("err-msg.test".to_string(), QueryType::A),
&query,
|| async { Err::<DnsPacket, _>("connection refused by upstream".into()) },
)
.await;
assert_eq!(path, QueryPath::UpstreamError);
assert_eq!(
err.as_deref(),
Some("connection refused by upstream"),
"error message must be preserved for logging"
);
} }
} }

View File

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

View File

@@ -17,8 +17,7 @@ use numa::query_log::QueryLog;
use numa::service_store::ServiceStore; use numa::service_store::ServiceStore;
use numa::stats::ServerStats; use numa::stats::ServerStats;
use numa::system_dns::{ use numa::system_dns::{
discover_system_dns, install_service, install_system_dns, restart_service, service_status, discover_system_dns, install_service, restart_service, service_status, uninstall_service,
uninstall_service, uninstall_system_dns,
}; };
#[tokio::main] #[tokio::main]
@@ -31,12 +30,12 @@ async fn main() -> numa::Result<()> {
let arg1 = std::env::args().nth(1).unwrap_or_default(); let arg1 = std::env::args().nth(1).unwrap_or_default();
match arg1.as_str() { match arg1.as_str() {
"install" => { "install" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n"); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n");
return install_system_dns().map_err(|e| e.into()); return install_service().map_err(|e| e.into());
} }
"uninstall" => { "uninstall" => {
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n"); eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n");
return uninstall_system_dns().map_err(|e| e.into()); return uninstall_service().map_err(|e| e.into());
} }
"service" => { "service" => {
let sub = std::env::args().nth(2).unwrap_or_default(); let sub = std::env::args().nth(2).unwrap_or_default();
@@ -107,32 +106,81 @@ async fn main() -> numa::Result<()> {
// Discover system DNS in a single pass (upstream + forwarding rules) // Discover system DNS in a single pass (upstream + forwarding rules)
let system_dns = discover_system_dns(); let system_dns = discover_system_dns();
let upstream_addr = if config.upstream.address.is_empty() { let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
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 upstream: Upstream = if upstream_addr.starts_with("https://") { let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
let client = reqwest::Client::builder() numa::config::UpstreamMode::Auto => {
.use_rustls_tls() info!("auto mode: probing recursive resolution...");
.build() if numa::recursive::probe_recursive(&root_hints).await {
.unwrap_or_default(); info!("recursive probe succeeded — self-sovereign mode");
Upstream::Doh { let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
url: upstream_addr, (
client, 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 = "https://dns.quad9.net/dns-query".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");
"https://dns.quad9.net/dns-query".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 api_port = config.server.api_port;
let mut blocklist = BlocklistStore::new(); let mut blocklist = BlocklistStore::new();
@@ -183,7 +231,7 @@ async fn main() -> numa::Result<()> {
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)), lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
forwarding_rules, forwarding_rules,
upstream: Mutex::new(upstream), upstream: Mutex::new(upstream),
upstream_auto: config.upstream.address.is_empty(), upstream_auto,
upstream_port: config.upstream.port, upstream_port: config.upstream.port,
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)), lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
timeout: Duration::from_millis(config.upstream.timeout_ms), timeout: Duration::from_millis(config.upstream.timeout_ms),
@@ -199,8 +247,8 @@ async fn main() -> numa::Result<()> {
config_dir: numa::config_dir(), config_dir: numa::config_dir(),
data_dir: numa::data_dir(), data_dir: numa::data_dir(),
tls_config: initial_tls, tls_config: initial_tls,
upstream_mode: config.upstream.mode, upstream_mode: resolved_mode,
root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints), root_hints,
srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)), srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)),
inflight: std::sync::Mutex::new(std::collections::HashMap::new()), inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
dnssec_enabled: config.dnssec.enabled, dnssec_enabled: config.dnssec.enabled,
@@ -208,7 +256,6 @@ async fn main() -> numa::Result<()> {
}); });
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
// Build banner rows, then size the box to fit the longest value // Build banner rows, then size the box to fit the longest value
let api_url = format!("http://localhost:{}", api_port); let api_url = format!("http://localhost:{}", api_port);
let proxy_label = if config.proxy.enabled { let proxy_label = if config.proxy.enabled {
@@ -308,6 +355,17 @@ async fn main() -> numa::Result<()> {
); );
if let Some(ref label) = proxy_label { if let Some(ref label) = proxy_label {
row("Proxy", g, label); row("Proxy", g, label);
if config.proxy.bind_addr == "127.0.0.1" {
let y = "\x1b[38;2;204;176;59m"; // yellow
row(
"",
y,
&format!(
"⚠ proxy on 127.0.0.1 — .{} not LAN reachable",
config.proxy.tld
),
);
}
} }
if config.lan.enabled { if config.lan.enabled {
row("LAN", g, "mDNS (_numa._tcp.local)"); row("LAN", g, "mDNS (_numa._tcp.local)");
@@ -375,16 +433,11 @@ async fn main() -> numa::Result<()> {
axum::serve(listener, app).await.unwrap(); 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 = config
let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled { .proxy
std::net::Ipv4Addr::UNSPECIFIED .bind_addr
} else { .parse()
config .unwrap_or(std::net::Ipv4Addr::LOCALHOST);
.proxy
.bind_addr
.parse()
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
};
// Spawn HTTP reverse proxy for .numa domains // Spawn HTTP reverse proxy for .numa domains
if config.proxy.enabled { if config.proxy.enabled {

View File

@@ -117,6 +117,22 @@ impl OverrideStore {
self.entries.clear(); self.entries.clear();
} }
pub fn heap_bytes(&self) -> usize {
let per_slot = std::mem::size_of::<u64>()
+ std::mem::size_of::<String>()
+ std::mem::size_of::<OverrideEntry>()
+ 1;
let table = self.entries.capacity() * per_slot;
let heap: usize = self
.entries
.iter()
.map(|(k, v)| {
k.capacity() + v.domain.capacity() + v.target.capacity() + v.record.heap_bytes()
})
.sum();
table + heap
}
pub fn active_count(&self) -> usize { pub fn active_count(&self) -> usize {
self.entries.values().filter(|e| !e.is_expired()).count() self.entries.values().filter(|e| !e.is_expired()).count()
} }
@@ -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 { pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket {
let mut resp = DnsPacket::new(); let mut resp = DnsPacket::new();
resp.header.id = query.header.id; resp.header.id = query.header.id;
@@ -582,4 +610,16 @@ mod tests {
panic!("expected DNSKEY"); panic!("expected DNSKEY");
} }
} }
#[test]
fn heap_bytes_accounts_for_records() {
let mut pkt = DnsPacket::new();
let empty = pkt.heap_bytes();
pkt.answers.push(DnsRecord::A {
domain: "example.com".into(),
addr: "1.2.3.4".parse().unwrap(),
ttl: 300,
});
assert!(pkt.heap_bytes() > empty);
}
} }

View File

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

View File

@@ -136,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) { pub fn set_ttl(&mut self, new_ttl: u32) {
match self { match self {
DnsRecord::A { ttl, .. } DnsRecord::A { ttl, .. }
@@ -650,4 +690,14 @@ mod tests {
let parsed = round_trip(&rec); let parsed = round_trip(&rec);
assert_eq!(rec, parsed); assert_eq!(rec, parsed);
} }
#[test]
fn heap_bytes_reflects_string_capacity() {
let rec = DnsRecord::CNAME {
domain: "a]".repeat(100),
host: "b".repeat(200),
ttl: 60,
};
assert!(rec.heap_bytes() >= 300);
}
} }

View File

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

View File

@@ -100,6 +100,14 @@ impl SrttCache {
addrs.sort_by_key(|a| self.get(a.ip())); addrs.sort_by_key(|a| self.get(a.ip()));
} }
pub fn heap_bytes(&self) -> usize {
let per_slot = std::mem::size_of::<u64>()
+ std::mem::size_of::<IpAddr>()
+ std::mem::size_of::<SrttEntry>()
+ 1;
self.entries.capacity() * per_slot
}
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.entries.len() self.entries.len()
} }
@@ -303,6 +311,16 @@ mod tests {
assert_eq!(addrs, vec![sock(1), sock(2)]); 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] #[test]
fn eviction_removes_oldest() { fn eviction_removes_oldest() {
let mut cache = SrttCache::new(true); let mut cache = SrttCache::new(true);

View File

@@ -1,5 +1,92 @@
use std::time::Instant; use std::time::Instant;
/// Returns the process memory footprint in bytes, or 0 if unavailable.
/// macOS: phys_footprint (matches Activity Monitor). Linux: RSS from /proc/self/statm.
pub fn process_memory_bytes() -> usize {
#[cfg(target_os = "macos")]
{
macos_rss()
}
#[cfg(target_os = "linux")]
{
linux_rss()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
0
}
}
#[cfg(target_os = "macos")]
fn macos_rss() -> usize {
use std::mem;
extern "C" {
fn mach_task_self() -> u32;
fn task_info(
target_task: u32,
flavor: u32,
task_info_out: *mut TaskVmInfo,
task_info_count: *mut u32,
) -> i32;
}
// Partial task_vm_info_data_t — only fields up to phys_footprint.
#[repr(C)]
struct TaskVmInfo {
virtual_size: u64,
region_count: i32,
page_size: i32,
resident_size: u64,
resident_size_peak: u64,
device: u64,
device_peak: u64,
internal: u64,
internal_peak: u64,
external: u64,
external_peak: u64,
reusable: u64,
reusable_peak: u64,
purgeable_volatile_pmap: u64,
purgeable_volatile_resident: u64,
purgeable_volatile_virtual: u64,
compressed: u64,
compressed_peak: u64,
compressed_lifetime: u64,
phys_footprint: u64,
}
const TASK_VM_INFO: u32 = 22;
let mut info: TaskVmInfo = unsafe { mem::zeroed() };
let mut count = (mem::size_of::<TaskVmInfo>() / mem::size_of::<u32>()) as u32;
let kr = unsafe { task_info(mach_task_self(), TASK_VM_INFO, &mut info, &mut count) };
if kr == 0 {
info.phys_footprint as usize
} else {
0
}
}
#[cfg(target_os = "linux")]
fn linux_rss() -> usize {
extern "C" {
fn sysconf(name: i32) -> i64;
}
const SC_PAGESIZE: i32 = 30; // x86_64 + aarch64; differs on mips (28), sparc (29)
let page_size = unsafe { sysconf(SC_PAGESIZE) };
let page_size = if page_size > 0 {
page_size as usize
} else {
4096
};
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
if let Some(rss_pages) = statm.split_whitespace().nth(1) {
if let Ok(pages) = rss_pages.parse::<usize>() {
return pages * page_size;
}
}
}
0
}
pub struct ServerStats { pub struct ServerStats {
queries_total: u64, queries_total: u64,
queries_forwarded: u64, queries_forwarded: u64,
@@ -13,7 +100,7 @@ pub struct ServerStats {
started_at: Instant, started_at: Instant,
} }
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum QueryPath { pub enum QueryPath {
Local, Local,
Cached, Cached,

View File

@@ -2,6 +2,10 @@ use std::net::SocketAddr;
use log::info; 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`. /// A conditional forwarding rule: domains matching `suffix` are forwarded to `upstream`.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ForwardingRule { pub struct ForwardingRule {
@@ -26,10 +30,7 @@ pub fn discover_system_dns() -> SystemDnsInfo {
} }
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
SystemDnsInfo { discover_linux()
default_upstream: detect_upstream_linux_or_backup(),
forwarding_rules: Vec::new(),
}
} }
#[cfg(windows)] #[cfg(windows)]
{ {
@@ -102,11 +103,7 @@ fn discover_macos() -> SystemDnsInfo {
if ns.parse::<std::net::Ipv4Addr>().is_ok() { if ns.parse::<std::net::Ipv4Addr>().is_ok() {
current_nameserver = Some(ns.clone()); current_nameserver = Some(ns.clone());
// Capture first non-supplemental, non-loopback nameserver as default upstream // Capture first non-supplemental, non-loopback nameserver as default upstream
if !is_supplemental if !is_supplemental && default_upstream.is_none() && !is_loopback_or_stub(&ns) {
&& default_upstream.is_none()
&& ns != "127.0.0.1"
&& ns != "0.0.0.0"
{
default_upstream = Some(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> { fn make_rule(domain: &str, nameserver: &str) -> Option<ForwardingRule> {
let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?; let addr: SocketAddr = format!("{}:53", nameserver).parse().ok()?;
Some(ForwardingRule { 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")] #[cfg(target_os = "linux")]
fn detect_upstream_linux_or_backup() -> Option<String> { const CLOUD_VPC_RESOLVER: &str = "169.254.169.253";
// 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
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn read_upstream_from_file(path: &str) -> Option<String> { fn discover_linux() -> SystemDnsInfo {
let text = std::fs::read_to_string(path).ok()?; // 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() { for line in text.lines() {
let line = line.trim(); let line = line.trim();
if line.starts_with("nameserver") { if line.starts_with("nameserver") {
if let Some(ns) = line.split_whitespace().nth(1) { if upstream.is_none() {
if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" { if let Some(ns) = line.split_whitespace().nth(1) {
return Some(ns.to_string()); 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 // Take the first non-loopback DNS server
for addr in inner.split(',') { for addr in inner.split(',') {
let addr = addr.trim(); let addr = addr.trim();
if !addr.is_empty() if !is_loopback_or_stub(addr) && addr.parse::<std::net::Ipv4Addr>().is_ok()
&& addr != "127.0.0.1"
&& addr != "0.0.0.0"
&& addr.parse::<std::net::Ipv4Addr>().is_ok()
{ {
log::info!("detected DHCP DNS: {}", addr); log::info!("detected DHCP DNS: {}", addr);
return Some(addr.to_string()); return Some(addr.to_string());
@@ -278,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo {
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") { if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
if let Some(ip) = trimmed.split(':').next_back() { if let Some(ip) = trimmed.split(':').next_back() {
let ip = ip.trim(); let ip = ip.trim();
if !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" { if !is_loopback_or_stub(ip) {
upstream = Some(ip.to_string()); upstream = Some(ip.to_string());
break; break;
} }
@@ -316,43 +372,6 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
// --- System DNS configuration (install/uninstall) --- // --- 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 --- // --- macOS implementation ---
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -500,21 +519,25 @@ const SYSTEMD_UNIT: &str = "/etc/systemd/system/numa.service";
/// Install Numa as a system service that starts on boot and auto-restarts. /// Install Numa as a system service that starts on boot and auto-restarts.
pub fn install_service() -> Result<(), String> { pub fn install_service() -> Result<(), String> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ let result = install_service_macos();
install_service_macos()
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ let result = install_service_linux();
install_service_linux()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))] #[cfg(not(any(target_os = "macos", target_os = "linux")))]
{ let result = Err::<(), String>("service installation not supported on this OS".to_string());
Err("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. /// Uninstall the Numa system service.
pub fn uninstall_service() -> Result<(), String> { pub fn uninstall_service() -> Result<(), String> {
let _ = untrust_ca();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
{ {
uninstall_service_macos() uninstall_service_macos()
@@ -609,7 +632,7 @@ fn install_service_macos() -> Result<(), String> {
std::fs::write(PLIST_DEST, plist) std::fs::write(PLIST_DEST, plist)
.map_err(|e| format!("failed to write {}: {}", PLIST_DEST, e))?; .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") let status = std::process::Command::new("launchctl")
.args(["load", "-w", PLIST_DEST]) .args(["load", "-w", PLIST_DEST])
.status() .status()
@@ -619,14 +642,34 @@ fn install_service_macos() -> Result<(), String> {
return Err("launchctl load failed".to_string()); return Err("launchctl load failed".to_string());
} }
// Set system DNS to 127.0.0.1 now that the service is running // Wait for numa to be ready before redirecting DNS
eprintln!(" Service installed and started."); 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() { if let Err(e) = install_macos() {
eprintln!(" warning: failed to configure system DNS: {}", e); 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!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: /usr/local/var/log/numa.log"); 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(()) Ok(())
} }
@@ -708,8 +751,11 @@ fn install_linux() -> Result<(), String> {
.map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?; .map_err(|e| format!("failed to create {}: {}", resolved_dir.display(), e))?;
let drop_in = resolved_dir.join("numa.conf"); let drop_in = resolved_dir.join("numa.conf");
std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n") std::fs::write(
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?; &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"]); let _ = run_systemctl(&["restart", "systemd-resolved"]);
eprintln!(" systemd-resolved detected."); eprintln!(" systemd-resolved detected.");
@@ -802,17 +848,21 @@ fn install_service_linux() -> Result<(), String> {
run_systemctl(&["daemon-reload"])?; run_systemctl(&["daemon-reload"])?;
run_systemctl(&["enable", "numa"])?; run_systemctl(&["enable", "numa"])?;
run_systemctl(&["start", "numa"])?;
eprintln!(" Service installed and started."); // Configure system DNS before starting numa so resolved releases port 53 first
// Set system DNS now that the service is running
if let Err(e) = install_linux() { if let Err(e) = install_linux() {
eprintln!(" warning: failed to configure system DNS: {}", e); 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!(" Numa will auto-start on boot and restart if killed.");
eprintln!(" Logs: journalctl -u numa -f"); 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(()) Ok(())
} }