Compare commits
85 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4a8893214 | ||
|
|
d979cd9505 | ||
|
|
8c421b9fa3 | ||
|
|
ad7884f2f6 | ||
|
|
6a70ab0f1b | ||
|
|
0b883d1c0d | ||
|
|
7f46f6271e | ||
|
|
f3ca83246c | ||
|
|
da93a3cde3 | ||
|
|
98da440c84 | ||
|
|
4e5b88496c | ||
|
|
d5f7ce9e2d | ||
|
|
cc704be590 | ||
|
|
ff1200eb10 | ||
|
|
49535568d9 | ||
|
|
cd1beedf38 | ||
|
|
be52e5c305 | ||
|
|
669498e85f | ||
|
|
d325b92e44 | ||
|
|
261fd2e148 | ||
|
|
30e46e549c | ||
|
|
ac49658c2b | ||
|
|
5265f571d0 | ||
|
|
0ebd924825 | ||
|
|
06d4e91cd2 | ||
|
|
71dbb138bc | ||
|
|
fbf3ca6d11 | ||
|
|
a84f2e7f1d | ||
|
|
7aee90c99b | ||
|
|
1304b1c02c | ||
|
|
59397ecce4 | ||
|
|
f849a4d65f | ||
|
|
962b400f4c | ||
|
|
1f4063d5db | ||
|
|
c6bc307f0a | ||
|
|
c5208e934d | ||
|
|
d69b79451e | ||
|
|
0b194256a9 | ||
|
|
e0c1997056 | ||
|
|
9e07064c94 | ||
|
|
43cedf11f7 | ||
|
|
cd6a54c652 | ||
|
|
9f89627c5a | ||
|
|
e7e5c173f2 | ||
|
|
c6b35045d8 | ||
|
|
10f1602803 | ||
|
|
41a97bb930 | ||
|
|
c4e733c8ef | ||
|
|
4020776b8e | ||
|
|
763ba1de91 | ||
|
|
51dc06690e | ||
|
|
fb89b78226 | ||
|
|
64c4d146ec | ||
|
|
9c290b6ef4 | ||
|
|
c836903db5 | ||
|
|
5e5a6544bc | ||
|
|
227af04564 | ||
|
|
4c58ff49b0 | ||
|
|
d261e8bc86 | ||
|
|
2de337ac36 | ||
|
|
5810ee5aac | ||
|
|
06850de728 | ||
|
|
995916d01b | ||
|
|
7aca3b1991 | ||
|
|
b7d64a9707 | ||
|
|
c333705a0e | ||
|
|
50d17ae118 | ||
|
|
5495107c9e | ||
|
|
02e83ccd72 | ||
|
|
ccbf893b92 | ||
|
|
cd90b50d68 | ||
|
|
5866ff1ba1 | ||
|
|
9a3de2f231 | ||
|
|
6fdadd637c | ||
|
|
9041ccc2e1 | ||
|
|
c9f1d98f45 | ||
|
|
6a8e47bbb5 | ||
|
|
de50720834 | ||
|
|
216ec76640 | ||
|
|
08aaebec7e | ||
|
|
3e40f795da | ||
|
|
8dcebaaca6 | ||
|
|
a48809fc25 | ||
|
|
e94e75101f | ||
|
|
32f50cd254 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -37,3 +37,10 @@ jobs:
|
|||||||
run: cargo build
|
run: cargo build
|
||||||
- name: clippy
|
- name: clippy
|
||||||
run: cargo clippy -- -D warnings
|
run: cargo clippy -- -D warnings
|
||||||
|
- name: test
|
||||||
|
run: cargo test
|
||||||
|
- name: Upload binary
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: numa-windows-x86_64
|
||||||
|
path: target/debug/numa.exe
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.7.0"
|
version = "0.9.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.7.0"
|
version = "0.9.1"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
@@ -10,7 +10,7 @@ keywords = ["dns", "dns-server", "ad-blocking", "reverse-proxy", "developer-tool
|
|||||||
categories = ["network-programming", "development-tools"]
|
categories = ["network-programming", "development-tools"]
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|||||||
8
Makefile
8
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt check audit test coverage bench clean deploy blog
|
.PHONY: all build lint fmt check audit test coverage bench clean deploy blog release
|
||||||
|
|
||||||
all: lint build test
|
all: lint build test
|
||||||
|
|
||||||
@@ -33,6 +33,12 @@ blog:
|
|||||||
echo " $$f → site/blog/posts/$$name.html"; \
|
echo " $$f → site/blog/posts/$$name.html"; \
|
||||||
done
|
done
|
||||||
|
|
||||||
|
release:
|
||||||
|
ifndef VERSION
|
||||||
|
$(error Usage: make release VERSION=0.8.0)
|
||||||
|
endif
|
||||||
|
./scripts/release.sh $(VERSION)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
cargo clean
|
cargo clean
|
||||||
|
|
||||||
|
|||||||
203
README.md
203
README.md
@@ -8,189 +8,118 @@
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install (pick one)
|
# macOS
|
||||||
brew install razvandimescu/tap/numa
|
brew install razvandimescu/tap/numa
|
||||||
cargo install numa
|
|
||||||
|
# Linux
|
||||||
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||||
|
|
||||||
# Run (port 53 requires root)
|
# Windows — download from GitHub Releases
|
||||||
sudo numa
|
# All platforms
|
||||||
|
cargo install numa
|
||||||
|
```
|
||||||
|
|
||||||
# Try it
|
```bash
|
||||||
dig @127.0.0.1 google.com # ✓ resolves normally
|
sudo numa # run in foreground (port 53 requires root/admin)
|
||||||
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:
|
||||||
|
|
||||||
```bash
|
| Platform | Install | Uninstall |
|
||||||
# Point your system DNS to Numa (saves originals for uninstall)
|
|----------|---------|-----------|
|
||||||
sudo numa install
|
| macOS | `sudo numa install` | `sudo numa uninstall` |
|
||||||
|
| Linux | `sudo numa install` | `sudo numa uninstall` |
|
||||||
|
| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot |
|
||||||
|
|
||||||
# Run as a persistent service (auto-starts on boot, restarts if killed)
|
On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry.
|
||||||
sudo numa service start
|
|
||||||
```
|
|
||||||
|
|
||||||
To uninstall: `sudo numa service stop` removes the service, `sudo numa uninstall` restores your original DNS.
|
## Local Services
|
||||||
|
|
||||||
### Upgrade
|
Name your dev services instead of remembering port numbers:
|
||||||
|
|
||||||
```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.
|
||||||
|
|
||||||
|
Three resolution modes:
|
||||||
|
|
||||||
|
- **`forward`** (default) — transparent proxy to your existing system DNS. Everything works as before, just with caching and ad blocking on top. Captive portals, VPNs, corporate DNS — all respected.
|
||||||
|
- **`recursive`** — resolve directly from root nameservers. No upstream dependency, no single entity sees your full query pattern. Add `[dnssec] enabled = true` for full chain-of-trust validation.
|
||||||
|
- **`auto`** — probe root servers on startup, recursive if reachable, encrypted DoH fallback if blocked.
|
||||||
|
|
||||||
|
DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||||
|
|
||||||
|
## LAN Discovery
|
||||||
|
|
||||||
|
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, macOS/Linux/Windows |
|
||||||
| 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
|
||||||
|
|
||||||
|
|||||||
@@ -50,17 +50,7 @@ TLD priming solves this. On startup, Numa queries root for NS records of 34 comm
|
|||||||
|
|
||||||
DNSSEC doesn't encrypt DNS traffic. It *signs* it. Every DNS record can have an accompanying RRSIG (signature) record. The resolver verifies the signature against the zone's DNSKEY, then verifies that DNSKEY against the parent zone's DS (delegation signer) record, walking up until it reaches the root trust anchor — a hardcoded public key that IANA publishes and the entire internet agrees on.
|
DNSSEC doesn't encrypt DNS traffic. It *signs* it. Every DNS record can have an accompanying RRSIG (signature) record. The resolver verifies the signature against the zone's DNSKEY, then verifies that DNSKEY against the parent zone's DS (delegation signer) record, walking up until it reaches the root trust anchor — a hardcoded public key that IANA publishes and the entire internet agrees on.
|
||||||
|
|
||||||
```
|
<img src="../dnssec-chain.svg" alt="DNSSEC chain of trust diagram — verifying cloudflare.com from answer through .com TLD to root trust anchor">
|
||||||
cloudflare.com A 104.16.132.229
|
|
||||||
signed by → RRSIG (key_tag=34505, algo=13, signer=cloudflare.com)
|
|
||||||
verified with → DNSKEY (cloudflare.com, key_tag=34505, ECDSA P-256)
|
|
||||||
vouched for by → DS (at .com, key_tag=2371, digest=SHA-256 of cloudflare's DNSKEY)
|
|
||||||
signed by → RRSIG (key_tag=19718, signer=com)
|
|
||||||
verified with → DNSKEY (com, key_tag=19718)
|
|
||||||
vouched for by → DS (at root, key_tag=30909)
|
|
||||||
signed by → RRSIG (signer=.)
|
|
||||||
verified with → DNSKEY (., key_tag=20326) ← root trust anchor (hardcoded)
|
|
||||||
```
|
|
||||||
|
|
||||||
### How keys get there
|
### How keys get there
|
||||||
|
|
||||||
@@ -165,11 +155,9 @@ The network fetch dominates. The crypto is noise.
|
|||||||
|
|
||||||
## Surviving hostile networks
|
## Surviving hostile networks
|
||||||
|
|
||||||
I deployed Numa as my system DNS and switched to a different network. Everything broke. Every query: SERVFAIL, 3-second timeout.
|
I deployed Numa as my system DNS and switched networks. Everything broke — every query SERVFAIL, 3-second timeout. The ISP blocks outbound UDP port 53 to everything except whitelisted public resolvers. Root servers, TLD servers, authoritative servers — all unreachable over UDP.
|
||||||
|
|
||||||
The network probe told the story: the ISP blocks outbound UDP port 53 to all servers except a handful of whitelisted public resolvers (Google, Cloudflare). Root servers, TLD servers, authoritative servers — all unreachable over UDP. The ISP forces you onto their DNS or a blessed upstream. Recursive resolution is impossible.
|
But TCP port 53 worked. Every DNS server is required to support TCP (RFC 1035 section 4.2.2). The ISP only filters UDP.
|
||||||
|
|
||||||
Except TCP port 53 worked fine. And every DNS server is required to support TCP (RFC 1035 section 4.2.2). The ISP apparently only filters UDP.
|
|
||||||
|
|
||||||
The fix has three parts:
|
The fix has three parts:
|
||||||
|
|
||||||
|
|||||||
@@ -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 " sudo numa service start # run as persistent service"
|
|
||||||
echo " open http://localhost:5380 # dashboard"
|
echo " open http://localhost:5380 # dashboard"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " Other commands:"
|
||||||
|
echo " sudo numa # run in foreground (no service)"
|
||||||
|
echo " sudo numa uninstall # restore original DNS"
|
||||||
|
echo ""
|
||||||
|
|||||||
@@ -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]]
|
||||||
|
|||||||
43
scripts/release.sh
Executable file
43
scripts/release.sh
Executable file
@@ -0,0 +1,43 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "Usage: $0 <version> (e.g. 0.7.0)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
VERSION="$1"
|
||||||
|
TAG="v$VERSION"
|
||||||
|
|
||||||
|
# Sanity checks
|
||||||
|
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||||
|
echo "ERROR: working tree is dirty — commit or stash first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(git branch --show-current)" != "main" ]; then
|
||||||
|
echo "ERROR: must be on main branch" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if git tag -l "$TAG" | grep -q .; then
|
||||||
|
echo "ERROR: tag $TAG already exists" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT=$(grep '^version = ' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
|
||||||
|
echo "Bumping $CURRENT -> $VERSION"
|
||||||
|
|
||||||
|
# Bump version
|
||||||
|
sed -i.bak "s/^version = \"$CURRENT\"/version = \"$VERSION\"/" Cargo.toml
|
||||||
|
rm -f Cargo.toml.bak
|
||||||
|
cargo update --workspace
|
||||||
|
|
||||||
|
# Commit, tag, push
|
||||||
|
git add Cargo.toml Cargo.lock
|
||||||
|
git commit -m "chore: bump version to $VERSION"
|
||||||
|
git tag "$TAG"
|
||||||
|
git push origin main --tags
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Released $TAG — GitHub Actions will build, publish to crates.io, and create the release."
|
||||||
136
site/blog/dnssec-chain.svg
Normal file
136
site/blog/dnssec-chain.svg
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 680" font-family="'DM Sans', system-ui, sans-serif" font-size="13">
|
||||||
|
<defs>
|
||||||
|
<marker id="arr" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arr-amber" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#c0623a"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="arr-teal" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||||
|
<path d="M 0 0 L 10 5 L 0 10 z" fill="#6b7c4e"/>
|
||||||
|
</marker>
|
||||||
|
<filter id="s" x="-3%" y="-3%" width="106%" height="106%">
|
||||||
|
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-opacity="0.06"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background -->
|
||||||
|
<rect width="720" height="680" rx="8" fill="#faf7f2"/>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<text x="360" y="36" text-anchor="middle" font-size="15" font-weight="600" fill="#2c2418" font-family="'Instrument Serif', Georgia, serif" letter-spacing="-0.02em">DNSSEC Chain of Trust</text>
|
||||||
|
<text x="360" y="54" text-anchor="middle" font-size="11" fill="#a39888">Verifying cloudflare.com — from answer to root trust anchor</text>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<g transform="translate(28, 72)">
|
||||||
|
<rect width="14" height="14" rx="3" fill="#c0623a" opacity="0.15" stroke="#c0623a" stroke-width="1"/>
|
||||||
|
<text x="20" y="12" font-size="11" fill="#6b5e4f">Verify signature (RRSIG → DNSKEY)</text>
|
||||||
|
<rect x="230" width="14" height="14" rx="3" fill="#6b7c4e" opacity="0.15" stroke="#6b7c4e" stroke-width="1"/>
|
||||||
|
<text x="250" y="12" font-size="11" fill="#6b5e4f">Vouch for key (DS → parent DNSKEY)</text>
|
||||||
|
<rect x="478" width="14" height="14" rx="3" fill="#2c2418" opacity="0.08" stroke="#2c2418" stroke-opacity="0.15" stroke-width="1"/>
|
||||||
|
<text x="498" y="12" font-size="11" fill="#6b5e4f">DNS record / key</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- ═══ ZONE: cloudflare.com ═══ -->
|
||||||
|
<rect x="40" y="104" width="640" height="152" rx="8" fill="none" stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,3"/>
|
||||||
|
<text x="56" y="122" font-size="10" font-weight="600" fill="#a39888" letter-spacing="0.08em" font-family="'JetBrains Mono', monospace">CLOUDFLARE.COM ZONE</text>
|
||||||
|
|
||||||
|
<!-- A record -->
|
||||||
|
<rect x="80" y="138" width="320" height="38" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
|
||||||
|
<text x="96" y="157" font-size="12" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">cloudflare.com A 104.16.132.229</text>
|
||||||
|
<text x="96" y="170" font-size="10" fill="#a39888">The answer we want to verify</text>
|
||||||
|
|
||||||
|
<!-- RRSIG -->
|
||||||
|
<line x1="400" y1="157" x2="440" y2="157" stroke="#c0623a" stroke-width="1.5" marker-end="url(#arr-amber)"/>
|
||||||
|
<text x="412" y="149" font-size="9" fill="#c0623a" font-weight="600">signed by</text>
|
||||||
|
|
||||||
|
<rect x="445" y="138" width="220" height="38" rx="6" fill="rgba(192,98,58,0.06)" stroke="rgba(192,98,58,0.2)" filter="url(#s)"/>
|
||||||
|
<text x="461" y="155" font-size="11" font-weight="600" fill="#9e4e2d" font-family="'JetBrains Mono', monospace">RRSIG</text>
|
||||||
|
<text x="505" y="155" font-size="11" fill="#6b5e4f">tag=34505, algo=13</text>
|
||||||
|
<text x="461" y="170" font-size="10" fill="#a39888">signer: cloudflare.com</text>
|
||||||
|
|
||||||
|
<!-- DNSKEY -->
|
||||||
|
<rect x="80" y="192" width="320" height="50" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
|
||||||
|
<text x="96" y="211" font-size="11" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">DNSKEY</text>
|
||||||
|
<text x="156" y="211" font-size="11" fill="#6b5e4f">cloudflare.com, tag=34505</text>
|
||||||
|
<text x="96" y="228" font-size="11" fill="#6b7c4e" font-weight="500">ECDSA P-256</text>
|
||||||
|
<text x="194" y="228" font-size="10" fill="#a39888">— 174ns to verify</text>
|
||||||
|
|
||||||
|
<!-- RRSIG → DNSKEY arrow -->
|
||||||
|
<path d="M 555 176 L 555 192 L 400 192 L 400 200" stroke="#c0623a" stroke-width="1.5" fill="none" marker-end="url(#arr-amber)"/>
|
||||||
|
<text x="460" y="189" font-size="9" fill="#c0623a" font-weight="600">verified with</text>
|
||||||
|
|
||||||
|
<!-- ═══ ZONE: .com ═══ -->
|
||||||
|
<rect x="40" y="270" width="640" height="132" rx="8" fill="none" stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,3"/>
|
||||||
|
<text x="56" y="288" font-size="10" font-weight="600" fill="#a39888" letter-spacing="0.08em" font-family="'JetBrains Mono', monospace">.COM TLD ZONE</text>
|
||||||
|
|
||||||
|
<!-- DS connecting zones -->
|
||||||
|
<line x1="240" y1="242" x2="240" y2="302" stroke="#6b7c4e" stroke-width="1.5" marker-end="url(#arr-teal)"/>
|
||||||
|
<text x="252" y="276" font-size="9" fill="#6b7c4e" font-weight="600">vouched for by</text>
|
||||||
|
|
||||||
|
<!-- DS record at .com -->
|
||||||
|
<rect x="80" y="304" width="320" height="38" rx="6" fill="rgba(107,124,78,0.06)" stroke="rgba(107,124,78,0.2)" filter="url(#s)"/>
|
||||||
|
<text x="96" y="321" font-size="11" font-weight="600" fill="#566540" font-family="'JetBrains Mono', monospace">DS</text>
|
||||||
|
<text x="118" y="321" font-size="11" fill="#6b5e4f">tag=2371, digest=SHA-256</text>
|
||||||
|
<text x="96" y="336" font-size="10" fill="#a39888">hash of cloudflare.com DNSKEY</text>
|
||||||
|
|
||||||
|
<!-- DS signed by RRSIG -->
|
||||||
|
<line x1="400" y1="323" x2="440" y2="323" stroke="#c0623a" stroke-width="1.5" marker-end="url(#arr-amber)"/>
|
||||||
|
<text x="412" y="315" font-size="9" fill="#c0623a" font-weight="600">signed by</text>
|
||||||
|
|
||||||
|
<rect x="445" y="304" width="220" height="38" rx="6" fill="rgba(192,98,58,0.06)" stroke="rgba(192,98,58,0.2)" filter="url(#s)"/>
|
||||||
|
<text x="461" y="321" font-size="11" font-weight="600" fill="#9e4e2d" font-family="'JetBrains Mono', monospace">RRSIG</text>
|
||||||
|
<text x="505" y="321" font-size="11" fill="#6b5e4f">tag=19718, signer=com</text>
|
||||||
|
|
||||||
|
<!-- .com DNSKEY -->
|
||||||
|
<rect x="80" y="356" width="320" height="32" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
|
||||||
|
<text x="96" y="377" font-size="11" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">DNSKEY</text>
|
||||||
|
<text x="156" y="377" font-size="11" fill="#6b5e4f">com, tag=19718</text>
|
||||||
|
|
||||||
|
<!-- RRSIG → .com DNSKEY -->
|
||||||
|
<path d="M 555 342 L 555 356 L 400 356 L 400 366" stroke="#c0623a" stroke-width="1.5" fill="none" marker-end="url(#arr-amber)"/>
|
||||||
|
<text x="460" y="353" font-size="9" fill="#c0623a" font-weight="600">verified with</text>
|
||||||
|
|
||||||
|
<!-- ═══ ZONE: root ═══ -->
|
||||||
|
<rect x="40" y="404" width="640" height="132" rx="8" fill="none" stroke="rgba(0,0,0,0.06)" stroke-dasharray="4,3"/>
|
||||||
|
<text x="56" y="422" font-size="10" font-weight="600" fill="#a39888" letter-spacing="0.08em" font-family="'JetBrains Mono', monospace">ROOT ZONE (.)</text>
|
||||||
|
|
||||||
|
<!-- DS connecting .com → root -->
|
||||||
|
<line x1="240" y1="388" x2="240" y2="436" stroke="#6b7c4e" stroke-width="1.5" marker-end="url(#arr-teal)"/>
|
||||||
|
<text x="252" y="416" font-size="9" fill="#6b7c4e" font-weight="600">vouched for by</text>
|
||||||
|
|
||||||
|
<!-- DS at root -->
|
||||||
|
<rect x="80" y="438" width="320" height="38" rx="6" fill="rgba(107,124,78,0.06)" stroke="rgba(107,124,78,0.2)" filter="url(#s)"/>
|
||||||
|
<text x="96" y="455" font-size="11" font-weight="600" fill="#566540" font-family="'JetBrains Mono', monospace">DS</text>
|
||||||
|
<text x="118" y="455" font-size="11" fill="#6b5e4f">tag=30909, digest=SHA-256</text>
|
||||||
|
<text x="96" y="470" font-size="10" fill="#a39888">hash of com DNSKEY</text>
|
||||||
|
|
||||||
|
<!-- DS signed by root RRSIG -->
|
||||||
|
<line x1="400" y1="457" x2="440" y2="457" stroke="#c0623a" stroke-width="1.5" marker-end="url(#arr-amber)"/>
|
||||||
|
<text x="412" y="449" font-size="9" fill="#c0623a" font-weight="600">signed by</text>
|
||||||
|
|
||||||
|
<rect x="445" y="438" width="220" height="38" rx="6" fill="rgba(192,98,58,0.06)" stroke="rgba(192,98,58,0.2)" filter="url(#s)"/>
|
||||||
|
<text x="461" y="455" font-size="11" font-weight="600" fill="#9e4e2d" font-family="'JetBrains Mono', monospace">RRSIG</text>
|
||||||
|
<text x="505" y="455" font-size="11" fill="#6b5e4f">signer=.</text>
|
||||||
|
|
||||||
|
<!-- Root DNSKEY -->
|
||||||
|
<rect x="80" y="490" width="320" height="32" rx="6" fill="white" stroke="rgba(0,0,0,0.08)" filter="url(#s)"/>
|
||||||
|
<text x="96" y="511" font-size="11" font-weight="600" fill="#2c2418" font-family="'JetBrains Mono', monospace">DNSKEY</text>
|
||||||
|
<text x="156" y="511" font-size="11" fill="#6b5e4f">root (.), tag=20326, RSA/SHA-256</text>
|
||||||
|
|
||||||
|
<!-- RRSIG → root DNSKEY -->
|
||||||
|
<path d="M 555 476 L 555 490 L 400 490 L 400 500" stroke="#c0623a" stroke-width="1.5" fill="none" marker-end="url(#arr-amber)"/>
|
||||||
|
<text x="460" y="487" font-size="9" fill="#c0623a" font-weight="600">verified with</text>
|
||||||
|
|
||||||
|
<!-- ═══ TRUST ANCHOR ═══ -->
|
||||||
|
<line x1="240" y1="522" x2="240" y2="558" stroke="#2c2418" stroke-width="2" stroke-dasharray="4,3"/>
|
||||||
|
|
||||||
|
<rect x="120" y="560" width="480" height="52" rx="8" fill="#2c2418" filter="url(#s)"/>
|
||||||
|
<text x="360" y="582" text-anchor="middle" font-size="12" font-weight="600" fill="#faf7f2" font-family="'JetBrains Mono', monospace">ROOT TRUST ANCHOR</text>
|
||||||
|
<text x="360" y="600" text-anchor="middle" font-size="11" fill="#a39888">IANA KSK, key_tag=20326 — hardcoded in Numa as const [u8; 256]</text>
|
||||||
|
|
||||||
|
<!-- Flow summary -->
|
||||||
|
<text x="360" y="646" text-anchor="middle" font-size="12" fill="#6b5e4f" font-style="italic">Trust flows up (DS records). Keys flow down (DNSKEY → RRSIG).</text>
|
||||||
|
<text x="360" y="664" text-anchor="middle" font-size="11" fill="#a39888">If any link breaks — wrong signature, missing DS, expired RRSIG — Numa rejects the response.</text>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 9.2 KiB |
@@ -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;
|
||||||
@@ -285,6 +287,7 @@ body {
|
|||||||
.path-tag.OVERRIDE { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
|
.path-tag.OVERRIDE { background: rgba(82, 122, 82, 0.12); color: var(--emerald); }
|
||||||
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
|
.path-tag.SERVFAIL { background: rgba(181, 68, 58, 0.12); color: var(--rose); }
|
||||||
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
|
.path-tag.BLOCKED { background: rgba(163, 152, 136, 0.15); color: var(--text-dim); }
|
||||||
|
.path-tag.COALESCED { background: rgba(138, 104, 158, 0.12); color: var(--violet-dim); }
|
||||||
|
|
||||||
/* Sidebar panels */
|
/* Sidebar panels */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -467,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; }
|
||||||
@@ -523,6 +590,11 @@ body {
|
|||||||
<div class="stat-value" id="uptime">—</div>
|
<div class="stat-value" id="uptime">—</div>
|
||||||
<div class="stat-sub" id="uptimeSub"> </div>
|
<div class="stat-sub" id="uptimeSub"> </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"> </div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Resolution paths -->
|
<!-- Resolution paths -->
|
||||||
@@ -547,6 +619,8 @@ body {
|
|||||||
<select id="logFilterPath" onchange="applyLogFilter()"
|
<select id="logFilterPath" onchange="applyLogFilter()"
|
||||||
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
|
style="font-family:var(--font-mono);font-size:0.7rem;padding:0.25rem 0.4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-secondary);outline:none;">
|
||||||
<option value="">all paths</option>
|
<option value="">all paths</option>
|
||||||
|
<option value="RECURSIVE">recursive</option>
|
||||||
|
<option value="COALESCED">coalesced</option>
|
||||||
<option value="FORWARD">forward</option>
|
<option value="FORWARD">forward</option>
|
||||||
<option value="CACHED">cached</option>
|
<option value="CACHED">cached</option>
|
||||||
<option value="BLOCKED">blocked</option>
|
<option value="BLOCKED">blocked</option>
|
||||||
@@ -645,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">
|
||||||
@@ -709,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' },
|
||||||
@@ -879,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';
|
||||||
@@ -942,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 + '%';
|
||||||
|
|
||||||
@@ -954,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';
|
||||||
@@ -1233,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>
|
||||||
|
|||||||
@@ -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 & Protect</h3>
|
<h3>Resolve & Protect</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Recursive resolution — resolve from root nameservers, no upstream needed</li>
|
<li>Forward mode by default — transparent proxy to your existing DNS, with caching</li>
|
||||||
<li>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
|
||||||
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||||
<li>DNS-over-HTTPS — encrypted upstream as alternative to recursive mode</li>
|
<li>Recursive resolution — opt-in, resolve from root nameservers, no upstream needed</li>
|
||||||
|
<li>DNSSEC validation — 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 — macOS, Linux, and Windows</li>
|
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
65
src/api.rs
65
src/api.rs
@@ -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)]
|
||||||
@@ -182,6 +184,7 @@ struct QueriesStats {
|
|||||||
total: u64,
|
total: u64,
|
||||||
forwarded: u64,
|
forwarded: u64,
|
||||||
recursive: u64,
|
recursive: u64,
|
||||||
|
coalesced: u64,
|
||||||
cached: u64,
|
cached: u64,
|
||||||
local: u64,
|
local: u64,
|
||||||
overridden: u64,
|
overridden: u64,
|
||||||
@@ -208,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,
|
||||||
@@ -409,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) => (
|
||||||
@@ -475,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()
|
||||||
@@ -491,14 +518,16 @@ 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,
|
||||||
recursive: snap.recursive,
|
recursive: snap.recursive,
|
||||||
|
coalesced: snap.coalesced,
|
||||||
cached: snap.cached,
|
cached: snap.cached,
|
||||||
local: snap.local,
|
local: snap.local,
|
||||||
overridden: snap.overridden,
|
overridden: snap.overridden,
|
||||||
@@ -522,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(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -953,6 +993,7 @@ mod tests {
|
|||||||
upstream_mode: crate::config::UpstreamMode::Forward,
|
upstream_mode: crate::config::UpstreamMode::Forward,
|
||||||
root_hints: Vec::new(),
|
root_hints: Vec::new(),
|
||||||
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
|
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
|
||||||
|
inflight: Mutex::new(std::collections::HashMap::new()),
|
||||||
dnssec_enabled: false,
|
dnssec_enabled: false,
|
||||||
dnssec_strict: false,
|
dnssec_strict: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
40
src/cache.rs
40
src/cache.rs
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
604
src/ctx.rs
604
src/ctx.rs
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::{Mutex, RwLock};
|
use std::sync::{Mutex, RwLock};
|
||||||
@@ -7,6 +8,9 @@ use arc_swap::ArcSwap;
|
|||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use rustls::ServerConfig;
|
use rustls::ServerConfig;
|
||||||
use tokio::net::UdpSocket;
|
use tokio::net::UdpSocket;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
type InflightMap = HashMap<(String, QueryType), broadcast::Sender<Option<DnsPacket>>>;
|
||||||
|
|
||||||
use crate::blocklist::BlocklistStore;
|
use crate::blocklist::BlocklistStore;
|
||||||
use crate::buffer::BytePacketBuffer;
|
use crate::buffer::BytePacketBuffer;
|
||||||
@@ -53,6 +57,7 @@ pub struct ServerCtx {
|
|||||||
pub upstream_mode: UpstreamMode,
|
pub upstream_mode: UpstreamMode,
|
||||||
pub root_hints: Vec<SocketAddr>,
|
pub root_hints: Vec<SocketAddr>,
|
||||||
pub srtt: RwLock<SrttCache>,
|
pub srtt: RwLock<SrttCache>,
|
||||||
|
pub inflight: Mutex<InflightMap>,
|
||||||
pub dnssec_enabled: bool,
|
pub dnssec_enabled: bool,
|
||||||
pub dnssec_strict: bool,
|
pub dnssec_strict: bool,
|
||||||
}
|
}
|
||||||
@@ -88,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
|
||||||
@@ -108,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() {
|
||||||
|
if is_remote {
|
||||||
|
*ctx.lan_ip.lock().unwrap()
|
||||||
|
} else {
|
||||||
std::net::Ipv4Addr::LOCALHOST
|
std::net::Ipv4Addr::LOCALHOST
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut peers = ctx.lan_peers.lock().unwrap();
|
let mut peers = ctx.lan_peers.lock().unwrap();
|
||||||
peers
|
peers
|
||||||
@@ -125,38 +130,24 @@ pub async fn handle_query(
|
|||||||
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
let v6 = if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
|
||||||
match qtype {
|
|
||||||
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
|
||||||
domain: qname.clone(),
|
|
||||||
addr: if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
|
|
||||||
std::net::Ipv6Addr::LOCALHOST
|
std::net::Ipv6Addr::LOCALHOST
|
||||||
} else {
|
} else {
|
||||||
resolve_ip.to_ipv6_mapped()
|
resolve_ip.to_ipv6_mapped()
|
||||||
},
|
};
|
||||||
ttl: 300,
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
}),
|
resp.answers
|
||||||
_ => resp.answers.push(DnsRecord::A {
|
.push(sinkhole_record(&qname, qtype, resolve_ip, v6, 300));
|
||||||
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);
|
||||||
@@ -171,21 +162,20 @@ pub async fn handle_query(
|
|||||||
resp.header.authed_data = true;
|
resp.header.authed_data = true;
|
||||||
}
|
}
|
||||||
(resp, QueryPath::Cached, cached_dnssec)
|
(resp, QueryPath::Cached, cached_dnssec)
|
||||||
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
} else if let Some(fwd_addr) =
|
||||||
match crate::recursive::resolve_recursive(
|
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules)
|
||||||
&qname,
|
|
||||||
qtype,
|
|
||||||
&ctx.cache,
|
|
||||||
&query,
|
|
||||||
&ctx.root_hints,
|
|
||||||
&ctx.srtt,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
Ok(resp) => (resp, QueryPath::Recursive, DnssecStatus::Indeterminate),
|
// Conditional forwarding takes priority over recursive mode
|
||||||
|
// (e.g. Tailscale .ts.net, VPC private zones)
|
||||||
|
let upstream = Upstream::Udp(fwd_addr);
|
||||||
|
match forward_query(&query, &upstream, ctx.timeout).await {
|
||||||
|
Ok(resp) => {
|
||||||
|
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
||||||
|
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
error!(
|
||||||
"{} | {:?} {} | RECURSIVE ERROR | {}",
|
"{} | {:?} {} | FORWARD ERROR | {}",
|
||||||
src_addr, qtype, qname, e
|
src_addr, qtype, qname, e
|
||||||
);
|
);
|
||||||
(
|
(
|
||||||
@@ -195,6 +185,31 @@ pub async fn handle_query(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||||
|
let key = (qname.clone(), qtype);
|
||||||
|
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
|
||||||
|
crate::recursive::resolve_recursive(
|
||||||
|
&qname,
|
||||||
|
qtype,
|
||||||
|
&ctx.cache,
|
||||||
|
&query,
|
||||||
|
&ctx.root_hints,
|
||||||
|
&ctx.srtt,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
if path == QueryPath::Coalesced {
|
||||||
|
debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname);
|
||||||
|
} else if path == QueryPath::UpstreamError {
|
||||||
|
error!(
|
||||||
|
"{} | {:?} {} | RECURSIVE ERROR | {}",
|
||||||
|
src_addr,
|
||||||
|
qtype,
|
||||||
|
qname,
|
||||||
|
err.as_deref().unwrap_or("leader failed")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(resp, path, DnssecStatus::Indeterminate)
|
||||||
} else {
|
} else {
|
||||||
let upstream =
|
let upstream =
|
||||||
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
||||||
@@ -377,6 +392,105 @@ fn is_special_use_domain(qname: &str) -> bool {
|
|||||||
qname == "local" || qname.ends_with(".local")
|
qname == "local" || qname.ends_with(".local")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sinkhole_record(
|
||||||
|
domain: &str,
|
||||||
|
qtype: QueryType,
|
||||||
|
v4: std::net::Ipv4Addr,
|
||||||
|
v6: std::net::Ipv6Addr,
|
||||||
|
ttl: u32,
|
||||||
|
) -> DnsRecord {
|
||||||
|
match qtype {
|
||||||
|
QueryType::AAAA => DnsRecord::AAAA {
|
||||||
|
domain: domain.to_string(),
|
||||||
|
addr: v6,
|
||||||
|
ttl,
|
||||||
|
},
|
||||||
|
_ => DnsRecord::A {
|
||||||
|
domain: domain.to_string(),
|
||||||
|
addr: v4,
|
||||||
|
ttl,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Disposition {
|
||||||
|
Leader(broadcast::Sender<Option<DnsPacket>>),
|
||||||
|
Follower(broadcast::Receiver<Option<DnsPacket>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn acquire_inflight(inflight: &Mutex<InflightMap>, key: (String, QueryType)) -> Disposition {
|
||||||
|
let mut map = inflight.lock().unwrap();
|
||||||
|
if let Some(tx) = map.get(&key) {
|
||||||
|
Disposition::Follower(tx.subscribe())
|
||||||
|
} else {
|
||||||
|
let (tx, _) = broadcast::channel::<Option<DnsPacket>>(1);
|
||||||
|
map.insert(key, tx.clone());
|
||||||
|
Disposition::Leader(tx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run a resolve function with in-flight coalescing. Multiple concurrent calls
|
||||||
|
/// for the same key share a single resolution — the first caller (leader)
|
||||||
|
/// executes `resolve_fn`, and followers wait for the broadcast result.
|
||||||
|
async fn resolve_coalesced<F, Fut>(
|
||||||
|
inflight: &Mutex<InflightMap>,
|
||||||
|
key: (String, QueryType),
|
||||||
|
query: &DnsPacket,
|
||||||
|
resolve_fn: F,
|
||||||
|
) -> (DnsPacket, QueryPath, Option<String>)
|
||||||
|
where
|
||||||
|
F: FnOnce() -> Fut,
|
||||||
|
Fut: std::future::Future<Output = crate::Result<DnsPacket>>,
|
||||||
|
{
|
||||||
|
let disposition = acquire_inflight(inflight, key.clone());
|
||||||
|
|
||||||
|
match disposition {
|
||||||
|
Disposition::Follower(mut rx) => match rx.recv().await {
|
||||||
|
Ok(Some(mut resp)) => {
|
||||||
|
resp.header.id = query.header.id;
|
||||||
|
(resp, QueryPath::Coalesced, None)
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
DnsPacket::response_from(query, ResultCode::SERVFAIL),
|
||||||
|
QueryPath::UpstreamError,
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Disposition::Leader(tx) => {
|
||||||
|
let guard = InflightGuard { inflight, key };
|
||||||
|
let result = resolve_fn().await;
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
let _ = tx.send(Some(resp.clone()));
|
||||||
|
(resp, QueryPath::Recursive, None)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(None);
|
||||||
|
let err_msg = e.to_string();
|
||||||
|
(
|
||||||
|
DnsPacket::response_from(query, ResultCode::SERVFAIL),
|
||||||
|
QueryPath::UpstreamError,
|
||||||
|
Some(err_msg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct InflightGuard<'a> {
|
||||||
|
inflight: &'a Mutex<InflightMap>,
|
||||||
|
key: (String, QueryType),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for InflightGuard<'_> {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.inflight.lock().unwrap().remove(&self.key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
|
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
|
||||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
if qname == "ipv4only.arpa" {
|
if qname == "ipv4only.arpa" {
|
||||||
@@ -410,3 +524,391 @@ fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> Dns
|
|||||||
DnsPacket::response_from(query, ResultCode::NXDOMAIN)
|
DnsPacket::response_from(query, ResultCode::NXDOMAIN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
|
// ---- InflightGuard unit tests ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inflight_guard_removes_key_on_drop() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key = ("example.com".to_string(), QueryType::A);
|
||||||
|
let (tx, _) = broadcast::channel::<Option<DnsPacket>>(1);
|
||||||
|
map.lock().unwrap().insert(key.clone(), tx);
|
||||||
|
|
||||||
|
assert_eq!(map.lock().unwrap().len(), 1);
|
||||||
|
{
|
||||||
|
let _guard = InflightGuard {
|
||||||
|
inflight: &map,
|
||||||
|
key: key.clone(),
|
||||||
|
};
|
||||||
|
} // guard dropped here
|
||||||
|
assert!(map.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inflight_guard_only_removes_own_key() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key_a = ("a.com".to_string(), QueryType::A);
|
||||||
|
let key_b = ("b.com".to_string(), QueryType::A);
|
||||||
|
let (tx_a, _) = broadcast::channel::<Option<DnsPacket>>(1);
|
||||||
|
let (tx_b, _) = broadcast::channel::<Option<DnsPacket>>(1);
|
||||||
|
map.lock().unwrap().insert(key_a.clone(), tx_a);
|
||||||
|
map.lock().unwrap().insert(key_b.clone(), tx_b);
|
||||||
|
|
||||||
|
{
|
||||||
|
let _guard = InflightGuard {
|
||||||
|
inflight: &map,
|
||||||
|
key: key_a,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let m = map.lock().unwrap();
|
||||||
|
assert_eq!(m.len(), 1);
|
||||||
|
assert!(m.contains_key(&key_b));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn inflight_guard_same_domain_different_qtype_independent() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key_a = ("example.com".to_string(), QueryType::A);
|
||||||
|
let key_aaaa = ("example.com".to_string(), QueryType::AAAA);
|
||||||
|
let (tx_a, _) = broadcast::channel::<Option<DnsPacket>>(1);
|
||||||
|
let (tx_aaaa, _) = broadcast::channel::<Option<DnsPacket>>(1);
|
||||||
|
map.lock().unwrap().insert(key_a.clone(), tx_a);
|
||||||
|
map.lock().unwrap().insert(key_aaaa.clone(), tx_aaaa);
|
||||||
|
|
||||||
|
{
|
||||||
|
let _guard = InflightGuard {
|
||||||
|
inflight: &map,
|
||||||
|
key: key_a,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let m = map.lock().unwrap();
|
||||||
|
assert_eq!(m.len(), 1);
|
||||||
|
assert!(m.contains_key(&key_aaaa));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Coalescing disposition tests (via acquire_inflight) ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_caller_becomes_leader() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key = ("test.com".to_string(), QueryType::A);
|
||||||
|
|
||||||
|
let d = acquire_inflight(&map, key.clone());
|
||||||
|
assert!(matches!(d, Disposition::Leader(_)));
|
||||||
|
assert_eq!(map.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_caller_becomes_follower() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key = ("test.com".to_string(), QueryType::A);
|
||||||
|
|
||||||
|
let _leader = acquire_inflight(&map, key.clone());
|
||||||
|
let follower = acquire_inflight(&map, key);
|
||||||
|
assert!(matches!(follower, Disposition::Follower(_)));
|
||||||
|
// Map still has exactly 1 entry — follower subscribes, doesn't insert
|
||||||
|
assert_eq!(map.lock().unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn leader_broadcast_reaches_follower() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key = ("test.com".to_string(), QueryType::A);
|
||||||
|
|
||||||
|
let leader = acquire_inflight(&map, key.clone());
|
||||||
|
let follower = acquire_inflight(&map, key);
|
||||||
|
|
||||||
|
let tx = match leader {
|
||||||
|
Disposition::Leader(tx) => tx,
|
||||||
|
_ => panic!("expected leader"),
|
||||||
|
};
|
||||||
|
let mut rx = match follower {
|
||||||
|
Disposition::Follower(rx) => rx,
|
||||||
|
_ => panic!("expected follower"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut resp = DnsPacket::new();
|
||||||
|
resp.header.id = 42;
|
||||||
|
resp.answers.push(DnsRecord::A {
|
||||||
|
domain: "test.com".into(),
|
||||||
|
addr: Ipv4Addr::new(1, 2, 3, 4),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
let _ = tx.send(Some(resp));
|
||||||
|
|
||||||
|
let received = rx.recv().await.unwrap().unwrap();
|
||||||
|
assert_eq!(received.header.id, 42);
|
||||||
|
assert_eq!(received.answers.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn leader_none_signals_failure_to_follower() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key = ("test.com".to_string(), QueryType::A);
|
||||||
|
|
||||||
|
let leader = acquire_inflight(&map, key.clone());
|
||||||
|
let follower = acquire_inflight(&map, key);
|
||||||
|
|
||||||
|
let tx = match leader {
|
||||||
|
Disposition::Leader(tx) => tx,
|
||||||
|
_ => panic!("expected leader"),
|
||||||
|
};
|
||||||
|
let mut rx = match follower {
|
||||||
|
Disposition::Follower(rx) => rx,
|
||||||
|
_ => panic!("expected follower"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = tx.send(None);
|
||||||
|
assert!(rx.recv().await.unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_followers_all_receive_via_acquire() {
|
||||||
|
let map: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let key = ("multi.com".to_string(), QueryType::A);
|
||||||
|
|
||||||
|
let leader = acquire_inflight(&map, key.clone());
|
||||||
|
let f1 = acquire_inflight(&map, key.clone());
|
||||||
|
let f2 = acquire_inflight(&map, key.clone());
|
||||||
|
let f3 = acquire_inflight(&map, key);
|
||||||
|
|
||||||
|
let tx = match leader {
|
||||||
|
Disposition::Leader(tx) => tx,
|
||||||
|
_ => panic!("expected leader"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut resp = DnsPacket::new();
|
||||||
|
resp.answers.push(DnsRecord::A {
|
||||||
|
domain: "multi.com".into(),
|
||||||
|
addr: Ipv4Addr::new(10, 0, 0, 1),
|
||||||
|
ttl: 60,
|
||||||
|
});
|
||||||
|
let _ = tx.send(Some(resp));
|
||||||
|
|
||||||
|
for f in [f1, f2, f3] {
|
||||||
|
let mut rx = match f {
|
||||||
|
Disposition::Follower(rx) => rx,
|
||||||
|
_ => panic!("expected follower"),
|
||||||
|
};
|
||||||
|
let r = rx.recv().await.unwrap().unwrap();
|
||||||
|
assert_eq!(r.answers.len(), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Integration: resolve_coalesced with mock futures ----
|
||||||
|
|
||||||
|
fn mock_response(domain: &str) -> DnsPacket {
|
||||||
|
let mut resp = DnsPacket::new();
|
||||||
|
resp.header.response = true;
|
||||||
|
resp.header.rescode = ResultCode::NOERROR;
|
||||||
|
resp.answers.push(DnsRecord::A {
|
||||||
|
domain: domain.to_string(),
|
||||||
|
addr: Ipv4Addr::new(10, 0, 0, 1),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_queries_coalesce_to_single_resolution() {
|
||||||
|
let inflight = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for i in 0..5u16 {
|
||||||
|
let count = resolve_count.clone();
|
||||||
|
let inf = inflight.clone();
|
||||||
|
let key = ("coalesce.test".to_string(), QueryType::A);
|
||||||
|
let query = DnsPacket::query(100 + i, "coalesce.test", QueryType::A);
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
resolve_coalesced(&inf, key, &query, || async {
|
||||||
|
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
Ok(mock_response("coalesce.test"))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for h in handles {
|
||||||
|
let (_, path, _) = h.await.unwrap();
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
assert_eq!(actual, 1, "expected 1 resolution, got {}", actual);
|
||||||
|
|
||||||
|
let recursive = paths.iter().filter(|p| **p == QueryPath::Recursive).count();
|
||||||
|
let coalesced = paths.iter().filter(|p| **p == QueryPath::Coalesced).count();
|
||||||
|
assert_eq!(recursive, 1, "expected 1 RECURSIVE, got {}", recursive);
|
||||||
|
assert_eq!(coalesced, 4, "expected 4 COALESCED, got {}", coalesced);
|
||||||
|
|
||||||
|
assert!(inflight.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn different_qtypes_not_coalesced() {
|
||||||
|
let inflight = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||||
|
|
||||||
|
let inf1 = inflight.clone();
|
||||||
|
let inf2 = inflight.clone();
|
||||||
|
let count1 = resolve_count.clone();
|
||||||
|
let count2 = resolve_count.clone();
|
||||||
|
|
||||||
|
let query_a = DnsPacket::query(200, "same.domain", QueryType::A);
|
||||||
|
let query_aaaa = DnsPacket::query(201, "same.domain", QueryType::AAAA);
|
||||||
|
|
||||||
|
let h1 = tokio::spawn(async move {
|
||||||
|
resolve_coalesced(
|
||||||
|
&inf1,
|
||||||
|
("same.domain".to_string(), QueryType::A),
|
||||||
|
&query_a,
|
||||||
|
|| async {
|
||||||
|
count1.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
Ok(mock_response("same.domain"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
let h2 = tokio::spawn(async move {
|
||||||
|
resolve_coalesced(
|
||||||
|
&inf2,
|
||||||
|
("same.domain".to_string(), QueryType::AAAA),
|
||||||
|
&query_aaaa,
|
||||||
|
|| async {
|
||||||
|
count2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
|
Ok(mock_response("same.domain"))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
let (_, path1, _) = h1.await.unwrap();
|
||||||
|
let (_, path2, _) = h2.await.unwrap();
|
||||||
|
|
||||||
|
let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
assert_eq!(actual, 2, "A and AAAA should each resolve, got {}", actual);
|
||||||
|
assert_eq!(path1, QueryPath::Recursive);
|
||||||
|
assert_eq!(path2, QueryPath::Recursive);
|
||||||
|
|
||||||
|
assert!(inflight.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn inflight_map_cleaned_after_error() {
|
||||||
|
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let query = DnsPacket::query(300, "will-fail.test", QueryType::A);
|
||||||
|
|
||||||
|
let (_, path, _) = resolve_coalesced(
|
||||||
|
&inflight,
|
||||||
|
("will-fail.test".to_string(), QueryType::A),
|
||||||
|
&query,
|
||||||
|
|| async { Err::<DnsPacket, _>("upstream timeout".into()) },
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(path, QueryPath::UpstreamError);
|
||||||
|
assert!(inflight.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn follower_gets_servfail_when_leader_fails() {
|
||||||
|
let inflight = Arc::new(Mutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
for i in 0..3u16 {
|
||||||
|
let inf = inflight.clone();
|
||||||
|
let query = DnsPacket::query(400 + i, "fail.test", QueryType::A);
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
resolve_coalesced(
|
||||||
|
&inf,
|
||||||
|
("fail.test".to_string(), QueryType::A),
|
||||||
|
&query,
|
||||||
|
|| async {
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
Err::<DnsPacket, _>("upstream error".into())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut paths = Vec::new();
|
||||||
|
for h in handles {
|
||||||
|
let (resp, path, _) = h.await.unwrap();
|
||||||
|
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
|
||||||
|
assert_eq!(
|
||||||
|
resp.questions.len(),
|
||||||
|
1,
|
||||||
|
"SERVFAIL must echo question section"
|
||||||
|
);
|
||||||
|
assert_eq!(resp.questions[0].name, "fail.test");
|
||||||
|
paths.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let errors = paths
|
||||||
|
.iter()
|
||||||
|
.filter(|p| **p == QueryPath::UpstreamError)
|
||||||
|
.count();
|
||||||
|
assert_eq!(errors, 3, "all 3 should be UpstreamError, got {}", errors);
|
||||||
|
|
||||||
|
assert!(inflight.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn servfail_leader_includes_question_section() {
|
||||||
|
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let query = DnsPacket::query(500, "question.test", QueryType::A);
|
||||||
|
|
||||||
|
let (resp, _, _) = resolve_coalesced(
|
||||||
|
&inflight,
|
||||||
|
("question.test".to_string(), QueryType::A),
|
||||||
|
&query,
|
||||||
|
|| async { Err::<DnsPacket, _>("fail".into()) },
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(resp.header.rescode, ResultCode::SERVFAIL);
|
||||||
|
assert_eq!(
|
||||||
|
resp.questions.len(),
|
||||||
|
1,
|
||||||
|
"SERVFAIL must echo question section"
|
||||||
|
);
|
||||||
|
assert_eq!(resp.questions[0].name, "question.test");
|
||||||
|
assert_eq!(resp.questions[0].qtype, QueryType::A);
|
||||||
|
assert_eq!(resp.header.id, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn leader_error_preserves_message() {
|
||||||
|
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
||||||
|
let query = DnsPacket::query(700, "err-msg.test", QueryType::A);
|
||||||
|
|
||||||
|
let (_, path, err) = resolve_coalesced(
|
||||||
|
&inflight,
|
||||||
|
("err-msg.test".to_string(), QueryType::A),
|
||||||
|
&query,
|
||||||
|
|| async { Err::<DnsPacket, _>("connection refused by upstream".into()) },
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
assert_eq!(path, QueryPath::UpstreamError);
|
||||||
|
assert_eq!(
|
||||||
|
err.as_deref(),
|
||||||
|
Some("connection refused by upstream"),
|
||||||
|
"error message must be preserved for logging"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
108
src/main.rs
108
src/main.rs
@@ -17,10 +17,12 @@ 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,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const QUAD9_IP: &str = "9.9.9.9";
|
||||||
|
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> numa::Result<()> {
|
async fn main() -> numa::Result<()> {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||||
@@ -31,12 +33,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,13 +109,53 @@ async fn main() -> numa::Result<()> {
|
|||||||
// Discover system DNS in a single pass (upstream + forwarding rules)
|
// Discover system DNS in a single pass (upstream + forwarding rules)
|
||||||
let system_dns = discover_system_dns();
|
let system_dns = discover_system_dns();
|
||||||
|
|
||||||
|
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
|
||||||
|
|
||||||
|
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
|
||||||
|
numa::config::UpstreamMode::Auto => {
|
||||||
|
info!("auto mode: probing recursive resolution...");
|
||||||
|
if numa::recursive::probe_recursive(&root_hints).await {
|
||||||
|
info!("recursive probe succeeded — self-sovereign mode");
|
||||||
|
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||||
|
(
|
||||||
|
numa::config::UpstreamMode::Recursive,
|
||||||
|
false,
|
||||||
|
dummy,
|
||||||
|
"recursive (root hints)".to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let url = DOH_FALLBACK.to_string();
|
||||||
|
let label = url.clone();
|
||||||
|
(
|
||||||
|
numa::config::UpstreamMode::Forward,
|
||||||
|
false,
|
||||||
|
Upstream::Doh { url, client },
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
numa::config::UpstreamMode::Recursive => {
|
||||||
|
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
||||||
|
(
|
||||||
|
numa::config::UpstreamMode::Recursive,
|
||||||
|
false,
|
||||||
|
dummy,
|
||||||
|
"recursive (root hints)".to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
numa::config::UpstreamMode::Forward => {
|
||||||
let upstream_addr = if config.upstream.address.is_empty() {
|
let upstream_addr = if config.upstream.address.is_empty() {
|
||||||
system_dns
|
system_dns
|
||||||
.default_upstream
|
.default_upstream
|
||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||||
"https://dns.quad9.net/dns-query".to_string()
|
DOH_FALLBACK.to_string()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
config.upstream.address.clone()
|
config.upstream.address.clone()
|
||||||
@@ -129,10 +171,19 @@ async fn main() -> numa::Result<()> {
|
|||||||
client,
|
client,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
let addr: SocketAddr =
|
||||||
|
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
||||||
Upstream::Udp(addr)
|
Upstream::Udp(addr)
|
||||||
};
|
};
|
||||||
let upstream_label = upstream.to_string();
|
let label = upstream.to_string();
|
||||||
|
(
|
||||||
|
numa::config::UpstreamMode::Forward,
|
||||||
|
config.upstream.address.is_empty(),
|
||||||
|
upstream,
|
||||||
|
label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
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 +234,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,15 +250,15 @@ 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()),
|
||||||
dnssec_enabled: config.dnssec.enabled,
|
dnssec_enabled: config.dnssec.enabled,
|
||||||
dnssec_strict: config.dnssec.strict,
|
dnssec_strict: config.dnssec.strict,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
||||||
@@ -307,6 +358,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)");
|
||||||
@@ -374,16 +436,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 {
|
|
||||||
std::net::Ipv4Addr::UNSPECIFIED
|
|
||||||
} else {
|
|
||||||
config
|
|
||||||
.proxy
|
.proxy
|
||||||
.bind_addr
|
.bind_addr
|
||||||
.parse()
|
.parse()
|
||||||
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
.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 {
|
||||||
@@ -424,7 +481,14 @@ async fn main() -> numa::Result<()> {
|
|||||||
#[allow(clippy::infinite_loop)]
|
#[allow(clippy::infinite_loop)]
|
||||||
loop {
|
loop {
|
||||||
let mut buffer = BytePacketBuffer::new();
|
let mut buffer = BytePacketBuffer::new();
|
||||||
let (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?;
|
let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {
|
||||||
|
// Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e.into()),
|
||||||
|
};
|
||||||
|
|
||||||
let ctx = Arc::clone(&ctx);
|
let ctx = Arc::clone(&ctx);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -467,7 +531,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
|||||||
let new_addr = dns_info
|
let new_addr = dns_info
|
||||||
.default_upstream
|
.default_upstream
|
||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
.unwrap_or_else(|| "9.9.9.9".to_string());
|
.unwrap_or_else(|| QUAD9_IP.to_string());
|
||||||
if let Ok(new_sock) =
|
if let Ok(new_sock) =
|
||||||
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -21,7 +21,8 @@ const UDP_FAIL_THRESHOLD: u8 = 3;
|
|||||||
|
|
||||||
static QUERY_ID: AtomicU16 = AtomicU16::new(1);
|
static QUERY_ID: AtomicU16 = AtomicU16::new(1);
|
||||||
static UDP_FAILURES: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
static UDP_FAILURES: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0);
|
||||||
static UDP_DISABLED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
pub(crate) static UDP_DISABLED: std::sync::atomic::AtomicBool =
|
||||||
|
std::sync::atomic::AtomicBool::new(false);
|
||||||
|
|
||||||
fn next_id() -> u16 {
|
fn next_id() -> u16 {
|
||||||
QUERY_ID.fetch_add(1, Ordering::Relaxed)
|
QUERY_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
@@ -31,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);
|
||||||
@@ -45,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()
|
||||||
@@ -59,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],
|
||||||
@@ -295,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;
|
||||||
@@ -359,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)),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,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
|
||||||
@@ -467,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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,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()
|
||||||
@@ -1055,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
|
||||||
@@ -1119,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
|
||||||
|
|||||||
97
src/srtt.rs
97
src/srtt.rs
@@ -47,16 +47,19 @@ impl SrttCache {
|
|||||||
|
|
||||||
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
||||||
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
||||||
let age_secs = entry.updated_at.elapsed().as_secs();
|
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
|
||||||
if age_secs > DECAY_AFTER_SECS {
|
if age_secs > DECAY_AFTER_SECS {
|
||||||
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
||||||
let mut srtt = entry.srtt_ms;
|
let mut srtt = srtt_ms;
|
||||||
for _ in 0..periods {
|
for _ in 0..periods {
|
||||||
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
||||||
}
|
}
|
||||||
srtt
|
srtt
|
||||||
} else {
|
} else {
|
||||||
entry.srtt_ms
|
srtt_ms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +103,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()
|
||||||
}
|
}
|
||||||
@@ -203,6 +214,86 @@ mod tests {
|
|||||||
assert_eq!(addrs, original);
|
assert_eq!(addrs, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_decay_within_threshold() {
|
||||||
|
// At exactly DECAY_AFTER_SECS, no decay applied
|
||||||
|
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS);
|
||||||
|
assert_eq!(result, FAILURE_PENALTY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_decay_period() {
|
||||||
|
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1);
|
||||||
|
let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2;
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_decay_periods() {
|
||||||
|
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1);
|
||||||
|
let mut expected = FAILURE_PENALTY_MS;
|
||||||
|
for _ in 0..4 {
|
||||||
|
expected = (expected + INITIAL_SRTT_MS) / 2;
|
||||||
|
}
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decay_caps_at_8_periods() {
|
||||||
|
// 9 periods and 100 periods should produce the same result (capped at 8)
|
||||||
|
let a = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 9 + 1);
|
||||||
|
let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||||
|
assert_eq!(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decay_converges_toward_initial() {
|
||||||
|
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||||
|
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
||||||
|
assert!(
|
||||||
|
diff < 25,
|
||||||
|
"expected near INITIAL_SRTT_MS, got {} (diff={})",
|
||||||
|
decayed,
|
||||||
|
diff
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn record_rtt_applies_decay_before_ewma() {
|
||||||
|
// Verify decay is applied before EWMA in record_rtt by checking
|
||||||
|
// that a saturated penalty + long age + new sample produces a low SRTT
|
||||||
|
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8);
|
||||||
|
// EWMA: (decayed * 7 + 50) / 8
|
||||||
|
let after_ewma = (decayed * 7 + 50) / 8;
|
||||||
|
assert!(
|
||||||
|
after_ewma < 500,
|
||||||
|
"expected decay before EWMA, got srtt={}",
|
||||||
|
after_ewma
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decay_reranks_stale_failures() {
|
||||||
|
// After enough decay, a failed server (5000ms) converges toward
|
||||||
|
// INITIAL (200ms), which is below a stable server at 300ms
|
||||||
|
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||||
|
assert!(
|
||||||
|
decayed < 300,
|
||||||
|
"expected decayed penalty ({}) < 300ms",
|
||||||
|
decayed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn heap_bytes_grows_with_entries() {
|
||||||
|
let mut cache = SrttCache::new(true);
|
||||||
|
let empty = cache.heap_bytes();
|
||||||
|
for i in 1..=10u8 {
|
||||||
|
cache.record_rtt(ip(i), 100, false);
|
||||||
|
}
|
||||||
|
assert!(cache.heap_bytes() > empty);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn eviction_removes_oldest() {
|
fn eviction_removes_oldest() {
|
||||||
let mut cache = SrttCache::new(true);
|
let mut cache = SrttCache::new(true);
|
||||||
|
|||||||
101
src/stats.rs
101
src/stats.rs
@@ -1,9 +1,97 @@
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// Returns the process memory footprint in bytes, or 0 if unavailable.
|
||||||
|
/// macOS: phys_footprint (matches Activity Monitor). Linux: RSS from /proc/self/statm.
|
||||||
|
pub fn process_memory_bytes() -> usize {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
macos_rss()
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
|
linux_rss()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
|
{
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn macos_rss() -> usize {
|
||||||
|
use std::mem;
|
||||||
|
extern "C" {
|
||||||
|
fn mach_task_self() -> u32;
|
||||||
|
fn task_info(
|
||||||
|
target_task: u32,
|
||||||
|
flavor: u32,
|
||||||
|
task_info_out: *mut TaskVmInfo,
|
||||||
|
task_info_count: *mut u32,
|
||||||
|
) -> i32;
|
||||||
|
}
|
||||||
|
// Partial task_vm_info_data_t — only fields up to phys_footprint.
|
||||||
|
#[repr(C)]
|
||||||
|
struct TaskVmInfo {
|
||||||
|
virtual_size: u64,
|
||||||
|
region_count: i32,
|
||||||
|
page_size: i32,
|
||||||
|
resident_size: u64,
|
||||||
|
resident_size_peak: u64,
|
||||||
|
device: u64,
|
||||||
|
device_peak: u64,
|
||||||
|
internal: u64,
|
||||||
|
internal_peak: u64,
|
||||||
|
external: u64,
|
||||||
|
external_peak: u64,
|
||||||
|
reusable: u64,
|
||||||
|
reusable_peak: u64,
|
||||||
|
purgeable_volatile_pmap: u64,
|
||||||
|
purgeable_volatile_resident: u64,
|
||||||
|
purgeable_volatile_virtual: u64,
|
||||||
|
compressed: u64,
|
||||||
|
compressed_peak: u64,
|
||||||
|
compressed_lifetime: u64,
|
||||||
|
phys_footprint: u64,
|
||||||
|
}
|
||||||
|
const TASK_VM_INFO: u32 = 22;
|
||||||
|
let mut info: TaskVmInfo = unsafe { mem::zeroed() };
|
||||||
|
let mut count = (mem::size_of::<TaskVmInfo>() / mem::size_of::<u32>()) as u32;
|
||||||
|
let kr = unsafe { task_info(mach_task_self(), TASK_VM_INFO, &mut info, &mut count) };
|
||||||
|
if kr == 0 {
|
||||||
|
info.phys_footprint as usize
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn linux_rss() -> usize {
|
||||||
|
extern "C" {
|
||||||
|
fn sysconf(name: i32) -> i64;
|
||||||
|
}
|
||||||
|
const SC_PAGESIZE: i32 = 30; // x86_64 + aarch64; differs on mips (28), sparc (29)
|
||||||
|
let page_size = unsafe { sysconf(SC_PAGESIZE) };
|
||||||
|
let page_size = if page_size > 0 {
|
||||||
|
page_size as usize
|
||||||
|
} else {
|
||||||
|
4096
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
|
||||||
|
if let Some(rss_pages) = statm.split_whitespace().nth(1) {
|
||||||
|
if let Ok(pages) = rss_pages.parse::<usize>() {
|
||||||
|
return pages * page_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
0
|
||||||
|
}
|
||||||
|
|
||||||
pub struct ServerStats {
|
pub struct ServerStats {
|
||||||
queries_total: u64,
|
queries_total: u64,
|
||||||
queries_forwarded: u64,
|
queries_forwarded: u64,
|
||||||
queries_recursive: u64,
|
queries_recursive: u64,
|
||||||
|
queries_coalesced: u64,
|
||||||
queries_cached: u64,
|
queries_cached: u64,
|
||||||
queries_blocked: u64,
|
queries_blocked: u64,
|
||||||
queries_local: u64,
|
queries_local: u64,
|
||||||
@@ -12,12 +100,13 @@ 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,
|
||||||
Forwarded,
|
Forwarded,
|
||||||
Recursive,
|
Recursive,
|
||||||
|
Coalesced,
|
||||||
Blocked,
|
Blocked,
|
||||||
Overridden,
|
Overridden,
|
||||||
UpstreamError,
|
UpstreamError,
|
||||||
@@ -30,6 +119,7 @@ impl QueryPath {
|
|||||||
QueryPath::Cached => "CACHED",
|
QueryPath::Cached => "CACHED",
|
||||||
QueryPath::Forwarded => "FORWARD",
|
QueryPath::Forwarded => "FORWARD",
|
||||||
QueryPath::Recursive => "RECURSIVE",
|
QueryPath::Recursive => "RECURSIVE",
|
||||||
|
QueryPath::Coalesced => "COALESCED",
|
||||||
QueryPath::Blocked => "BLOCKED",
|
QueryPath::Blocked => "BLOCKED",
|
||||||
QueryPath::Overridden => "OVERRIDE",
|
QueryPath::Overridden => "OVERRIDE",
|
||||||
QueryPath::UpstreamError => "SERVFAIL",
|
QueryPath::UpstreamError => "SERVFAIL",
|
||||||
@@ -45,6 +135,8 @@ impl QueryPath {
|
|||||||
Some(QueryPath::Forwarded)
|
Some(QueryPath::Forwarded)
|
||||||
} else if s.eq_ignore_ascii_case("RECURSIVE") {
|
} else if s.eq_ignore_ascii_case("RECURSIVE") {
|
||||||
Some(QueryPath::Recursive)
|
Some(QueryPath::Recursive)
|
||||||
|
} else if s.eq_ignore_ascii_case("COALESCED") {
|
||||||
|
Some(QueryPath::Coalesced)
|
||||||
} else if s.eq_ignore_ascii_case("BLOCKED") {
|
} else if s.eq_ignore_ascii_case("BLOCKED") {
|
||||||
Some(QueryPath::Blocked)
|
Some(QueryPath::Blocked)
|
||||||
} else if s.eq_ignore_ascii_case("OVERRIDE") {
|
} else if s.eq_ignore_ascii_case("OVERRIDE") {
|
||||||
@@ -69,6 +161,7 @@ impl ServerStats {
|
|||||||
queries_total: 0,
|
queries_total: 0,
|
||||||
queries_forwarded: 0,
|
queries_forwarded: 0,
|
||||||
queries_recursive: 0,
|
queries_recursive: 0,
|
||||||
|
queries_coalesced: 0,
|
||||||
queries_cached: 0,
|
queries_cached: 0,
|
||||||
queries_blocked: 0,
|
queries_blocked: 0,
|
||||||
queries_local: 0,
|
queries_local: 0,
|
||||||
@@ -85,6 +178,7 @@ impl ServerStats {
|
|||||||
QueryPath::Cached => self.queries_cached += 1,
|
QueryPath::Cached => self.queries_cached += 1,
|
||||||
QueryPath::Forwarded => self.queries_forwarded += 1,
|
QueryPath::Forwarded => self.queries_forwarded += 1,
|
||||||
QueryPath::Recursive => self.queries_recursive += 1,
|
QueryPath::Recursive => self.queries_recursive += 1,
|
||||||
|
QueryPath::Coalesced => self.queries_coalesced += 1,
|
||||||
QueryPath::Blocked => self.queries_blocked += 1,
|
QueryPath::Blocked => self.queries_blocked += 1,
|
||||||
QueryPath::Overridden => self.queries_overridden += 1,
|
QueryPath::Overridden => self.queries_overridden += 1,
|
||||||
QueryPath::UpstreamError => self.upstream_errors += 1,
|
QueryPath::UpstreamError => self.upstream_errors += 1,
|
||||||
@@ -106,6 +200,7 @@ impl ServerStats {
|
|||||||
total: self.queries_total,
|
total: self.queries_total,
|
||||||
forwarded: self.queries_forwarded,
|
forwarded: self.queries_forwarded,
|
||||||
recursive: self.queries_recursive,
|
recursive: self.queries_recursive,
|
||||||
|
coalesced: self.queries_coalesced,
|
||||||
cached: self.queries_cached,
|
cached: self.queries_cached,
|
||||||
local: self.queries_local,
|
local: self.queries_local,
|
||||||
overridden: self.queries_overridden,
|
overridden: self.queries_overridden,
|
||||||
@@ -121,11 +216,12 @@ impl ServerStats {
|
|||||||
let secs = uptime.as_secs() % 60;
|
let secs = uptime.as_secs() % 60;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | cached {} | local {} | override {} | blocked {} | errors {}",
|
"STATS | uptime {}h{}m{}s | total {} | fwd {} | recursive {} | coalesced {} | cached {} | local {} | override {} | blocked {} | errors {}",
|
||||||
hours, mins, secs,
|
hours, mins, secs,
|
||||||
self.queries_total,
|
self.queries_total,
|
||||||
self.queries_forwarded,
|
self.queries_forwarded,
|
||||||
self.queries_recursive,
|
self.queries_recursive,
|
||||||
|
self.queries_coalesced,
|
||||||
self.queries_cached,
|
self.queries_cached,
|
||||||
self.queries_local,
|
self.queries_local,
|
||||||
self.queries_overridden,
|
self.queries_overridden,
|
||||||
@@ -140,6 +236,7 @@ pub struct StatsSnapshot {
|
|||||||
pub total: u64,
|
pub total: u64,
|
||||||
pub forwarded: u64,
|
pub forwarded: u64,
|
||||||
pub recursive: u64,
|
pub recursive: u64,
|
||||||
|
pub coalesced: u64,
|
||||||
pub cached: u64,
|
pub cached: u64,
|
||||||
pub local: u64,
|
pub local: u64,
|
||||||
pub overridden: u64,
|
pub overridden: u64,
|
||||||
|
|||||||
@@ -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") {
|
#[cfg(target_os = "linux")]
|
||||||
|
fn discover_linux() -> SystemDnsInfo {
|
||||||
|
// Parse resolv.conf once for both upstream and search domains
|
||||||
|
let (upstream, search_domains) = parse_resolv_conf("/etc/resolv.conf");
|
||||||
|
|
||||||
|
let default_upstream = if let Some(ns) = upstream {
|
||||||
info!("detected system upstream: {}", ns);
|
info!("detected system upstream: {}", ns);
|
||||||
return Some(ns);
|
Some(ns)
|
||||||
}
|
} else {
|
||||||
// If resolv.conf only has loopback, check the backup from `numa install`
|
// Fallback to backup from a previous `numa install`
|
||||||
let backup = {
|
let backup = {
|
||||||
let home = std::env::var("HOME")
|
let home = std::env::var("HOME")
|
||||||
.map(std::path::PathBuf::from)
|
.map(std::path::PathBuf::from)
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
|
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
|
||||||
home.join(".numa").join("original-resolv.conf")
|
home.join(".numa").join("original-resolv.conf")
|
||||||
};
|
};
|
||||||
if let Some(ns) = read_upstream_from_file(backup.to_str().unwrap_or("")) {
|
let (ns, _) = parse_resolv_conf(backup.to_str().unwrap_or(""));
|
||||||
|
if let Some(ref ns) = ns {
|
||||||
info!("detected original upstream from backup: {}", ns);
|
info!("detected original upstream from backup: {}", ns);
|
||||||
return Some(ns);
|
|
||||||
}
|
}
|
||||||
None
|
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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn read_upstream_from_file(path: &str) -> Option<String> {
|
fn parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
||||||
let text = std::fs::read_to_string(path).ok()?;
|
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 upstream.is_none() {
|
||||||
if let Some(ns) = line.split_whitespace().nth(1) {
|
if let Some(ns) = line.split_whitespace().nth(1) {
|
||||||
if ns != "127.0.0.1" && ns != "0.0.0.0" && ns != "::1" {
|
if !is_loopback_or_stub(ns) {
|
||||||
return Some(ns.to_string());
|
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 ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
||||||
upstream = Some(ip.to_string());
|
upstream = Some(ip.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -302,6 +358,339 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||||
|
struct WindowsInterfaceDns {
|
||||||
|
dhcp: bool,
|
||||||
|
servers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
|
||||||
|
let mut interfaces = std::collections::HashMap::new();
|
||||||
|
let mut current_adapter: Option<String> = None;
|
||||||
|
let mut current_dhcp = false;
|
||||||
|
let mut current_dns: Vec<String> = Vec::new();
|
||||||
|
let mut in_dns_block = false;
|
||||||
|
let mut disconnected = false;
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
|
||||||
|
// Adapter section headers start at column 0
|
||||||
|
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
|
||||||
|
if let Some(name) = current_adapter.take() {
|
||||||
|
if !disconnected {
|
||||||
|
interfaces.insert(
|
||||||
|
name,
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: current_dhcp,
|
||||||
|
servers: std::mem::take(&mut current_dns),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
current_dns.clear();
|
||||||
|
}
|
||||||
|
in_dns_block = false;
|
||||||
|
current_dhcp = false;
|
||||||
|
disconnected = false;
|
||||||
|
|
||||||
|
// "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German)
|
||||||
|
let lower = trimmed.to_lowercase();
|
||||||
|
if let Some(pos) = lower.find(" adapter ") {
|
||||||
|
let after = &trimmed[pos + " adapter ".len()..];
|
||||||
|
let name = after.trim_end_matches(':').trim();
|
||||||
|
if !name.is_empty() {
|
||||||
|
current_adapter = Some(name.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if current_adapter.is_some() {
|
||||||
|
if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") {
|
||||||
|
disconnected = true;
|
||||||
|
} else if trimmed.contains("DHCP") && trimmed.contains(". .") {
|
||||||
|
current_dhcp = trimmed
|
||||||
|
.split(':')
|
||||||
|
.next_back()
|
||||||
|
.map(|v| {
|
||||||
|
let v = v.trim().to_lowercase();
|
||||||
|
v == "yes" || v == "ja"
|
||||||
|
})
|
||||||
|
.unwrap_or(false);
|
||||||
|
in_dns_block = false;
|
||||||
|
} else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||||
|
in_dns_block = true;
|
||||||
|
if let Some(ip) = trimmed.split(':').next_back() {
|
||||||
|
let ip = ip.trim();
|
||||||
|
if ip.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
current_dns.push(ip.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if in_dns_block {
|
||||||
|
if trimmed.parse::<std::net::IpAddr>().is_ok() {
|
||||||
|
current_dns.push(trimmed.to_string());
|
||||||
|
} else {
|
||||||
|
in_dns_block = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(name) = current_adapter {
|
||||||
|
if !disconnected {
|
||||||
|
interfaces.insert(
|
||||||
|
name,
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: current_dhcp,
|
||||||
|
servers: current_dns,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interfaces
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn get_windows_interfaces() -> Result<std::collections::HashMap<String, WindowsInterfaceDns>, String>
|
||||||
|
{
|
||||||
|
let output = std::process::Command::new("ipconfig")
|
||||||
|
.arg("/all")
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to run ipconfig /all: {}", e))?;
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
Ok(parse_ipconfig_interfaces(&text))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn windows_backup_path() -> std::path::PathBuf {
|
||||||
|
// Use ProgramData (not APPDATA) since install requires admin elevation
|
||||||
|
// and APPDATA differs between user and admin contexts.
|
||||||
|
std::path::PathBuf::from(
|
||||||
|
std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()),
|
||||||
|
)
|
||||||
|
.join("numa")
|
||||||
|
.join("original-dns.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn disable_dnscache() -> Result<bool, String> {
|
||||||
|
// Check if Dnscache is running (it holds port 53 at kernel level)
|
||||||
|
let output = std::process::Command::new("sc")
|
||||||
|
.args(["query", "Dnscache"])
|
||||||
|
.output()
|
||||||
|
.map_err(|e| format!("failed to query Dnscache: {}", e))?;
|
||||||
|
let text = String::from_utf8_lossy(&output.stdout);
|
||||||
|
if !text.contains("RUNNING") {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(" Disabling DNS Client (Dnscache) to free port 53...");
|
||||||
|
// Dnscache can't be stopped via sc/net stop — must disable via registry
|
||||||
|
let status = std::process::Command::new("reg")
|
||||||
|
.args([
|
||||||
|
"add",
|
||||||
|
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
||||||
|
"/v",
|
||||||
|
"Start",
|
||||||
|
"/t",
|
||||||
|
"REG_DWORD",
|
||||||
|
"/d",
|
||||||
|
"4",
|
||||||
|
"/f",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to disable Dnscache: {}", e))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
return Err("failed to disable Dnscache via registry (run as Administrator?)".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(" Dnscache disabled. A reboot is required to free port 53.");
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn enable_dnscache() {
|
||||||
|
let _ = std::process::Command::new("reg")
|
||||||
|
.args([
|
||||||
|
"add",
|
||||||
|
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
||||||
|
"/v",
|
||||||
|
"Start",
|
||||||
|
"/t",
|
||||||
|
"REG_DWORD",
|
||||||
|
"/d",
|
||||||
|
"2",
|
||||||
|
"/f",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn install_windows() -> Result<(), String> {
|
||||||
|
let interfaces = get_windows_interfaces()?;
|
||||||
|
if interfaces.is_empty() {
|
||||||
|
return Err("no active network interfaces found".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = windows_backup_path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)
|
||||||
|
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(&interfaces)
|
||||||
|
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||||
|
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||||
|
|
||||||
|
for name in interfaces.keys() {
|
||||||
|
let status = std::process::Command::new("netsh")
|
||||||
|
.args([
|
||||||
|
"interface",
|
||||||
|
"ipv4",
|
||||||
|
"set",
|
||||||
|
"dnsservers",
|
||||||
|
name,
|
||||||
|
"static",
|
||||||
|
"127.0.0.1",
|
||||||
|
"primary",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to set DNS for {}: {}", name, e))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name);
|
||||||
|
} else {
|
||||||
|
eprintln!(
|
||||||
|
" warning: failed to set DNS for \"{}\" (run as Administrator?)",
|
||||||
|
name
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let needs_reboot = disable_dnscache()?;
|
||||||
|
register_autostart();
|
||||||
|
|
||||||
|
eprintln!("\n Original DNS saved to {}", path.display());
|
||||||
|
eprintln!(" Run 'numa uninstall' to restore.\n");
|
||||||
|
if needs_reboot {
|
||||||
|
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
||||||
|
} else {
|
||||||
|
eprintln!(" Numa will start automatically on next boot.\n");
|
||||||
|
}
|
||||||
|
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||||
|
eprintln!(" [upstream]");
|
||||||
|
eprintln!(" mode = \"recursive\"\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register numa to auto-start on boot via registry Run key.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn register_autostart() {
|
||||||
|
let exe = std::env::current_exe()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|_| "numa".into());
|
||||||
|
let _ = std::process::Command::new("reg")
|
||||||
|
.args([
|
||||||
|
"add",
|
||||||
|
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
||||||
|
"/v",
|
||||||
|
"Numa",
|
||||||
|
"/t",
|
||||||
|
"REG_SZ",
|
||||||
|
"/d",
|
||||||
|
&exe,
|
||||||
|
"/f",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
eprintln!(" Registered auto-start on boot.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove numa auto-start registry key.
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn remove_autostart() {
|
||||||
|
let _ = std::process::Command::new("reg")
|
||||||
|
.args([
|
||||||
|
"delete",
|
||||||
|
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
||||||
|
"/v",
|
||||||
|
"Numa",
|
||||||
|
"/f",
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
fn uninstall_windows() -> Result<(), String> {
|
||||||
|
remove_autostart();
|
||||||
|
let path = windows_backup_path();
|
||||||
|
let json = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
|
||||||
|
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
|
||||||
|
serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?;
|
||||||
|
|
||||||
|
for (name, dns_info) in &original {
|
||||||
|
if dns_info.dhcp || dns_info.servers.is_empty() {
|
||||||
|
let status = std::process::Command::new("netsh")
|
||||||
|
.args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||||
|
|
||||||
|
if status.success() {
|
||||||
|
eprintln!(" restored DNS for \"{}\" -> DHCP", name);
|
||||||
|
} else {
|
||||||
|
eprintln!(" warning: failed to restore DNS for \"{}\"", name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let status = std::process::Command::new("netsh")
|
||||||
|
.args([
|
||||||
|
"interface",
|
||||||
|
"ipv4",
|
||||||
|
"set",
|
||||||
|
"dnsservers",
|
||||||
|
name,
|
||||||
|
"static",
|
||||||
|
&dns_info.servers[0],
|
||||||
|
"primary",
|
||||||
|
])
|
||||||
|
.status()
|
||||||
|
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
eprintln!(" warning: failed to restore primary DNS for \"{}\"", name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i, server) in dns_info.servers.iter().skip(1).enumerate() {
|
||||||
|
let _ = std::process::Command::new("netsh")
|
||||||
|
.args([
|
||||||
|
"interface",
|
||||||
|
"ipv4",
|
||||||
|
"add",
|
||||||
|
"dnsservers",
|
||||||
|
name,
|
||||||
|
server,
|
||||||
|
&format!("index={}", i + 2),
|
||||||
|
])
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
|
||||||
|
eprintln!(
|
||||||
|
" restored DNS for \"{}\" -> {}",
|
||||||
|
name,
|
||||||
|
dns_info.servers.join(", ")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::fs::remove_file(&path).ok();
|
||||||
|
|
||||||
|
// Re-enable Dnscache
|
||||||
|
enable_dnscache();
|
||||||
|
eprintln!("\n System DNS restored. DNS Client re-enabled.");
|
||||||
|
eprintln!(" Reboot to fully restore the DNS Client service.\n");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the upstream for a domain by checking forwarding rules.
|
/// Find the upstream for a domain by checking forwarding rules.
|
||||||
/// Returns None if no rule matches (use default upstream).
|
/// Returns None if no rule matches (use default upstream).
|
||||||
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
||||||
@@ -316,43 +705,6 @@ pub fn match_forwarding_rule(domain: &str, rules: &[ForwardingRule]) -> Option<S
|
|||||||
|
|
||||||
// --- System DNS configuration (install/uninstall) ---
|
// --- 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")]
|
||||||
@@ -424,7 +776,7 @@ fn install_macos() -> Result<(), String> {
|
|||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||||
std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?;
|
std::fs::write(backup_path(), json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||||
|
|
||||||
// Set DNS to 127.0.0.1 for each service
|
// Set DNS to 127.0.0.1 and add "numa" search domain for each service
|
||||||
for service in &services {
|
for service in &services {
|
||||||
let status = std::process::Command::new("networksetup")
|
let status = std::process::Command::new("networksetup")
|
||||||
.args(["-setdnsservers", service, "127.0.0.1"])
|
.args(["-setdnsservers", service, "127.0.0.1"])
|
||||||
@@ -436,6 +788,11 @@ fn install_macos() -> Result<(), String> {
|
|||||||
} else {
|
} else {
|
||||||
eprintln!(" warning: failed to set DNS for \"{}\"", service);
|
eprintln!(" warning: failed to set DNS for \"{}\"", service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add "numa" as search domain so browsers resolve .numa without trailing slash
|
||||||
|
let _ = std::process::Command::new("networksetup")
|
||||||
|
.args(["-setsearchdomains", service, "numa"])
|
||||||
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
eprintln!("\n Original DNS saved to {}", backup_path().display());
|
eprintln!("\n Original DNS saved to {}", backup_path().display());
|
||||||
@@ -480,6 +837,11 @@ fn uninstall_macos() -> Result<(), String> {
|
|||||||
} else {
|
} else {
|
||||||
eprintln!(" warning: failed to restore DNS for \"{}\"", service);
|
eprintln!(" warning: failed to restore DNS for \"{}\"", service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear the "numa" search domain
|
||||||
|
let _ = std::process::Command::new("networksetup")
|
||||||
|
.args(["-setsearchdomains", service, "Empty"])
|
||||||
|
.status();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::fs::remove_file(&path).ok();
|
std::fs::remove_file(&path).ok();
|
||||||
@@ -500,21 +862,27 @@ 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(windows)]
|
||||||
|
let result = install_windows();
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
|
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
||||||
|
|
||||||
|
if result.is_ok() {
|
||||||
|
if let Err(e) = trust_ca() {
|
||||||
|
eprintln!(" warning: could not trust CA: {}", e);
|
||||||
|
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
|
||||||
{
|
|
||||||
Err("service installation not supported on this OS".to_string())
|
|
||||||
}
|
}
|
||||||
|
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()
|
||||||
@@ -523,7 +891,11 @@ pub fn uninstall_service() -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
uninstall_service_linux()
|
uninstall_service_linux()
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
uninstall_windows()
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
{
|
{
|
||||||
Err("service uninstallation not supported on this OS".to_string())
|
Err("service uninstallation not supported on this OS".to_string())
|
||||||
}
|
}
|
||||||
@@ -609,7 +981,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 +991,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,7 +1100,10 @@ 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(
|
||||||
|
&drop_in,
|
||||||
|
"[Resolve]\nDNS=127.0.0.1\nDomains=~. numa\nDNSStubListener=no\n",
|
||||||
|
)
|
||||||
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
||||||
|
|
||||||
let _ = run_systemctl(&["restart", "systemd-resolved"]);
|
let _ = run_systemctl(&["restart", "systemd-resolved"]);
|
||||||
@@ -745,7 +1140,7 @@ fn install_linux() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content =
|
let content =
|
||||||
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n";
|
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\nsearch numa\n";
|
||||||
std::fs::write(resolv, content)
|
std::fs::write(resolv, content)
|
||||||
.map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?;
|
.map_err(|e| format!("failed to write /etc/resolv.conf: {}", e))?;
|
||||||
|
|
||||||
@@ -802,17 +1197,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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,3 +1376,57 @@ fn untrust_ca() -> Result<(), String> {
|
|||||||
let _ = ca_path; // suppress unused warning on other platforms
|
let _ = ca_path; // suppress unused warning on other platforms
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ipconfig_dhcp_and_static() {
|
||||||
|
let sample = "\
|
||||||
|
Ethernet adapter Ethernet:
|
||||||
|
|
||||||
|
DHCP Enabled. . . . . . . . . . . : Yes
|
||||||
|
DNS Servers . . . . . . . . . . . : 8.8.8.8
|
||||||
|
8.8.4.4
|
||||||
|
|
||||||
|
Wireless LAN adapter Wi-Fi:
|
||||||
|
|
||||||
|
DHCP Enabled. . . . . . . . . . . : No
|
||||||
|
DNS Servers . . . . . . . . . . . : 1.1.1.1
|
||||||
|
";
|
||||||
|
let result = parse_ipconfig_interfaces(sample);
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
result["Ethernet"],
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: true,
|
||||||
|
servers: vec!["8.8.8.8".into(), "8.8.4.4".into()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result["Wi-Fi"],
|
||||||
|
WindowsInterfaceDns {
|
||||||
|
dhcp: false,
|
||||||
|
servers: vec!["1.1.1.1".into()],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_ipconfig_skips_disconnected() {
|
||||||
|
let sample = "\
|
||||||
|
Ethernet adapter Ethernet 2:
|
||||||
|
|
||||||
|
Media State . . . . . . . . . . . : Media disconnected
|
||||||
|
|
||||||
|
Wireless LAN adapter Wi-Fi:
|
||||||
|
|
||||||
|
DHCP Enabled. . . . . . . . . . . : Yes
|
||||||
|
DNS Servers . . . . . . . . . . . : 192.168.1.1
|
||||||
|
";
|
||||||
|
let result = parse_ipconfig_interfaces(sample);
|
||||||
|
assert_eq!(result.len(), 1);
|
||||||
|
assert!(result.contains_key("Wi-Fi"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user