Compare commits
67 Commits
fix/window
...
v0.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
882508297e | ||
|
|
2b241c5755 | ||
|
|
7510c8e068 | ||
|
|
87c321f3d4 | ||
|
|
edfccaa2b7 | ||
|
|
0c43240c01 | ||
|
|
b615a56586 | ||
|
|
7056766a84 | ||
|
|
ebfc31d793 | ||
|
|
b6703b4315 | ||
|
|
cc8d3c7a83 | ||
|
|
4dec0c89b5 | ||
|
|
ea840f5a07 | ||
|
|
df2856b57f | ||
|
|
236ef7b4f5 | ||
|
|
5d454cbed5 | ||
|
|
c1d425069f | ||
|
|
d274500308 | ||
|
|
9c313ef06a | ||
|
|
0d25fae4cf | ||
|
|
1ae2e23bb6 | ||
|
|
fe784addd2 | ||
|
|
a3a218ba5e | ||
|
|
e4594c7955 | ||
|
|
b85f599b8f | ||
|
|
03c164e339 | ||
|
|
2fce82e36c | ||
|
|
53ae4d1404 | ||
|
|
4748a4a4bb | ||
|
|
607470472d | ||
|
|
0dd7700665 | ||
|
|
dddc10336c | ||
|
|
4e723e8ee7 | ||
|
|
03ca0bcb28 | ||
|
|
c021d5a0c8 | ||
|
|
ed12659b26 | ||
|
|
eaab406515 | ||
|
|
9992418908 | ||
|
|
0a43feaf1a | ||
|
|
1bf11190d5 | ||
|
|
4f8afcd5b2 | ||
|
|
71cf0f0fc5 | ||
|
|
2b64e30bf7 | ||
|
|
4a1c98b02d | ||
|
|
55ea49b003 | ||
|
|
f01b2418cd | ||
|
|
32bff69113 | ||
|
|
0a39d98861 | ||
|
|
ca1f51652b | ||
|
|
a74d9a4bbb | ||
|
|
e564bd887e | ||
|
|
8bece0a0cd | ||
|
|
990c865f41 | ||
|
|
0ba2d3c72d | ||
|
|
def89ffe59 | ||
|
|
a29e4aeb96 | ||
|
|
d355f8d005 | ||
|
|
c410945222 | ||
|
|
b3f3a4f36c | ||
|
|
14b035387b | ||
|
|
d457ffc296 | ||
|
|
8ab50844c2 | ||
|
|
e04afe5b70 | ||
|
|
44113492f0 | ||
|
|
ec41f32d4e | ||
|
|
a35b0ea23c | ||
|
|
fbdb0a245f |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -27,17 +27,6 @@ jobs:
|
|||||||
- name: audit
|
- name: audit
|
||||||
run: cargo install cargo-audit && cargo audit
|
run: cargo install cargo-audit && cargo audit
|
||||||
|
|
||||||
check-macos:
|
|
||||||
runs-on: macos-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
|
||||||
- uses: Swatinem/rust-cache@v2
|
|
||||||
- name: clippy
|
|
||||||
run: cargo clippy -- -D warnings
|
|
||||||
- name: test
|
|
||||||
run: cargo test
|
|
||||||
|
|
||||||
check-windows:
|
check-windows:
|
||||||
runs-on: windows-latest
|
runs-on: windows-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -48,10 +37,3 @@ 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
|
|
||||||
|
|||||||
90
.github/workflows/release.yml
vendored
90
.github/workflows/release.yml
vendored
@@ -108,93 +108,3 @@ jobs:
|
|||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
*.sha256
|
*.sha256
|
||||||
|
|
||||||
update-homebrew:
|
|
||||||
needs: release
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Get version from tag
|
|
||||||
id: version
|
|
||||||
run: echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Download SHA256 files
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Extract checksums
|
|
||||||
id: sha
|
|
||||||
run: |
|
|
||||||
echo "macos_arm=$(awk '{print $1}' numa-macos-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "macos_x86=$(awk '{print $1}' numa-macos-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "linux_arm=$(awk '{print $1}' numa-linux-aarch64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "linux_x86=$(awk '{print $1}' numa-linux-x86_64.tar.gz.sha256)" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Update Homebrew formula
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
|
||||||
script: |
|
|
||||||
const version = '${{ steps.version.outputs.version }}';
|
|
||||||
const base = `https://github.com/razvandimescu/numa/releases/download/v${version}`;
|
|
||||||
const formula = `class Numa < Formula
|
|
||||||
desc "Portable DNS resolver with ad blocking, .numa local service proxy, and developer overrides"
|
|
||||||
homepage "https://github.com/razvandimescu/numa"
|
|
||||||
license "MIT"
|
|
||||||
version "${version}"
|
|
||||||
|
|
||||||
on_macos do
|
|
||||||
if Hardware::CPU.arm?
|
|
||||||
url "${base}/numa-macos-aarch64.tar.gz"
|
|
||||||
sha256 "${{ steps.sha.outputs.macos_arm }}"
|
|
||||||
else
|
|
||||||
url "${base}/numa-macos-x86_64.tar.gz"
|
|
||||||
sha256 "${{ steps.sha.outputs.macos_x86 }}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
on_linux do
|
|
||||||
if Hardware::CPU.arm?
|
|
||||||
url "${base}/numa-linux-aarch64.tar.gz"
|
|
||||||
sha256 "${{ steps.sha.outputs.linux_arm }}"
|
|
||||||
else
|
|
||||||
url "${base}/numa-linux-x86_64.tar.gz"
|
|
||||||
sha256 "${{ steps.sha.outputs.linux_x86 }}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def install
|
|
||||||
bin.install "numa"
|
|
||||||
end
|
|
||||||
|
|
||||||
def caveats
|
|
||||||
<<~EOS
|
|
||||||
Numa requires root to bind port 53:
|
|
||||||
sudo numa # start the DNS server
|
|
||||||
sudo numa install # set as system DNS
|
|
||||||
sudo numa service start # run as persistent service
|
|
||||||
|
|
||||||
Dashboard: http://localhost:5380
|
|
||||||
EOS
|
|
||||||
end
|
|
||||||
|
|
||||||
test do
|
|
||||||
assert_match "numa", shell_output("#{bin}/numa --version")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
`.replace(/^ /gm, '');
|
|
||||||
|
|
||||||
const { data: existing } = await github.rest.repos.getContent({
|
|
||||||
owner: 'razvandimescu',
|
|
||||||
repo: 'homebrew-tap',
|
|
||||||
path: 'numa.rb',
|
|
||||||
});
|
|
||||||
|
|
||||||
await github.rest.repos.createOrUpdateFileContents({
|
|
||||||
owner: 'razvandimescu',
|
|
||||||
repo: 'homebrew-tap',
|
|
||||||
path: 'numa.rb',
|
|
||||||
message: `numa ${version}`,
|
|
||||||
content: Buffer.from(formula).toString('base64'),
|
|
||||||
sha: existing.sha,
|
|
||||||
});
|
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.9.1"
|
version = "0.7.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.9.1"
|
version = "0.7.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"
|
||||||
|
|||||||
203
README.md
203
README.md
@@ -8,118 +8,189 @@
|
|||||||
|
|
||||||
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
|
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
|
||||||
|
|
||||||
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation. One ~8MB binary, everything embedded.
|
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Recursive resolution from root nameservers with full DNSSEC validation (chain-of-trust + NSEC/NSEC3 denial proofs). One ~8MB binary, no PHP, no web server, no database — everything is embedded.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
# Install (pick one)
|
||||||
brew install razvandimescu/tap/numa
|
brew install razvandimescu/tap/numa
|
||||||
|
cargo install numa
|
||||||
# Linux
|
|
||||||
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||||
|
|
||||||
# Windows — download from GitHub Releases
|
# Run (port 53 requires root)
|
||||||
# All platforms
|
sudo numa
|
||||||
cargo install numa
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
# Try it
|
||||||
sudo numa # run in foreground (port 53 requires root/admin)
|
dig @127.0.0.1 google.com # ✓ resolves normally
|
||||||
|
dig @127.0.0.1 ads.google.com # ✗ blocked → 0.0.0.0
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
|
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
|
||||||
|
|
||||||
Set as system DNS:
|
### Set as system resolver
|
||||||
|
|
||||||
| Platform | Install | Uninstall |
|
```bash
|
||||||
|----------|---------|-----------|
|
# Point your system DNS to Numa (saves originals for uninstall)
|
||||||
| macOS | `sudo numa install` | `sudo numa uninstall` |
|
sudo numa install
|
||||||
| Linux | `sudo numa install` | `sudo numa uninstall` |
|
|
||||||
| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot |
|
|
||||||
|
|
||||||
On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry.
|
# Run as a persistent service (auto-starts on boot, restarts if killed)
|
||||||
|
sudo numa service start
|
||||||
|
```
|
||||||
|
|
||||||
## Local Services
|
To uninstall: `sudo numa service stop` removes the service, `sudo numa uninstall` restores your original DNS.
|
||||||
|
|
||||||
Name your dev services instead of remembering port numbers:
|
### Upgrade
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From Homebrew
|
||||||
|
brew upgrade numa
|
||||||
|
|
||||||
|
# From source
|
||||||
|
make deploy # builds release, copies binary, re-signs, restarts service
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build from source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/razvandimescu/numa.git && cd numa
|
||||||
|
cargo build --release
|
||||||
|
sudo cp target/release/numa /usr/local/bin/numa
|
||||||
|
```
|
||||||
|
|
||||||
|
## Why Numa
|
||||||
|
|
||||||
|
- **Local service proxy** — `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. Like `/etc/hosts` but with auto TLS, a REST API, LAN discovery, and auto-revert.
|
||||||
|
- **Path-based routing** — `app.numa/api → :5001`, `app.numa/auth → :5002`. Route URL paths to different backends with optional prefix stripping. Like nginx location blocks, zero config files.
|
||||||
|
- **LAN service discovery** — Numa instances on the same network find each other automatically via mDNS. Access a teammate's `api.numa` from your machine. Opt-in via `[lan] enabled = true`.
|
||||||
|
- **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. Full REST API for scripting. Built-in diagnostics: `curl localhost:5380/diagnose/example.com` tells you exactly how any domain resolves.
|
||||||
|
- **DNS-over-HTTPS** — upstream queries encrypted via DoH. Your ISP sees HTTPS traffic, not DNS queries. Set `address = "https://9.9.9.9/dns-query"` in `[upstream]` or any DoH provider.
|
||||||
|
- **Ad blocking that travels with you** — 385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network: coffee shops, hotels, airports.
|
||||||
|
- **Sub-microsecond caching** — 691ns cached round-trip, ~2.0M queries/sec throughput, zero heap allocations in the I/O path. [Benchmarks](bench/).
|
||||||
|
- **Live dashboard** — real-time stats, query log, blocking controls, service management. LAN accessibility badges show which services are reachable from other devices.
|
||||||
|
- **macOS, Linux, and Windows** — `numa install` configures system DNS, `numa service start` runs as launchd/systemd service.
|
||||||
|
|
||||||
|
## Local Service Proxy
|
||||||
|
|
||||||
|
Name your local dev services with `.numa` domains:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST localhost:5380/services \
|
curl -X POST localhost:5380/services \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"name":"frontend","target_port":5173}'
|
-d '{"name":"frontend","target_port":5173}'
|
||||||
|
|
||||||
|
open http://frontend.numa # → proxied to localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
Now `https://frontend.numa` works in your browser — green lock, valid cert, WebSocket passthrough for HMR. No mkcert, no nginx, no `/etc/hosts`.
|
- **HTTPS with green lock** — auto-generated local CA + per-service TLS certs
|
||||||
|
- **WebSocket** — Vite/webpack HMR works through the proxy
|
||||||
|
- **Health checks** — dashboard shows green/red status per service
|
||||||
|
- **LAN sharing** — services bound to `0.0.0.0` are automatically discoverable by other Numa instances on the network. Dashboard shows "LAN" or "local only" per service.
|
||||||
|
- **Path-based routing** — route URL paths to different backends:
|
||||||
|
```toml
|
||||||
|
[[services]]
|
||||||
|
name = "app"
|
||||||
|
target_port = 3000
|
||||||
|
routes = [
|
||||||
|
{ path = "/api", port = 5001 },
|
||||||
|
{ path = "/auth", port = 5002, strip = true },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
`app.numa/api/users → :5001/api/users`, `app.numa/auth/login → :5002/login` (stripped)
|
||||||
|
- **Persistent** — services survive restarts
|
||||||
|
- Or configure in `numa.toml`:
|
||||||
|
|
||||||
Add path-based routing (`app.numa/api → :5001`), share services across machines via LAN discovery, or configure everything in [`numa.toml`](numa.toml).
|
```toml
|
||||||
|
[[services]]
|
||||||
|
name = "frontend"
|
||||||
|
target_port = 5173
|
||||||
|
```
|
||||||
|
|
||||||
## Ad Blocking & Privacy
|
## LAN Service Discovery
|
||||||
|
|
||||||
385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop.
|
Run Numa on multiple machines. They find each other automatically:
|
||||||
|
|
||||||
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 │
|
||||||
│ - api (port 8000) │◄───────────►│ - grafana (3000) │
|
│ services: │◄───────────►│ services: │
|
||||||
│ - frontend (5173) │ discovery │ │
|
│ - api (port 8000) │ discovery │ - grafana (3000) │
|
||||||
|
│ - frontend (5173) │ │ │
|
||||||
└──────────────────────┘ └──────────────────────┘
|
└──────────────────────┘ └──────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Enable with `numa lan on`.
|
From Machine B:
|
||||||
|
```bash
|
||||||
|
dig @127.0.0.1 api.numa # → 192.168.1.5
|
||||||
|
curl http://api.numa # → proxied to Machine A's port 8000
|
||||||
|
```
|
||||||
|
|
||||||
**Hub mode**: run one instance with `bind_addr = "0.0.0.0:53"` and point other devices' DNS to it — they get ad blocking + `.numa` resolution without installing anything.
|
Enable LAN discovery:
|
||||||
|
```bash
|
||||||
|
numa lan on
|
||||||
|
```
|
||||||
|
Or in `numa.toml`:
|
||||||
|
```toml
|
||||||
|
[lan]
|
||||||
|
enabled = true
|
||||||
|
```
|
||||||
|
Uses standard mDNS (`_numa._tcp.local` on port 5353) — compatible with Bonjour/Avahi, silently dropped by corporate firewalls instead of triggering IPS alerts.
|
||||||
|
|
||||||
|
**Hub mode** — don't want to install Numa on every machine? Run one instance as a shared DNS server and point other devices to it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the hub machine, bind to LAN interface
|
||||||
|
[server]
|
||||||
|
bind_addr = "0.0.0.0:53"
|
||||||
|
|
||||||
|
# On other devices, set DNS to the hub's IP
|
||||||
|
# They get .numa resolution, ad blocking, caching — zero install
|
||||||
|
```
|
||||||
|
|
||||||
## How It Compares
|
## How It Compares
|
||||||
|
|
||||||
| | Pi-hole | AdGuard Home | Unbound | Numa |
|
| | Pi-hole | AdGuard Home | NextDNS | Cloudflare | Numa |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| Local service proxy + auto TLS | — | — | — | `.numa` domains, HTTPS, WebSocket |
|
| Local service proxy | No | No | No | No | `.numa` + HTTPS + WS |
|
||||||
| LAN service discovery | — | — | — | mDNS, zero config |
|
| Path-based routing | No | No | No | No | Prefix match + strip |
|
||||||
| Developer overrides (REST API) | — | — | — | Auto-revert, scriptable |
|
| LAN service discovery | No | No | No | No | mDNS, opt-in |
|
||||||
| Recursive resolver | — | — | Yes | Yes, with SRTT selection |
|
| Developer overrides | No | No | No | No | REST API + auto-expiry |
|
||||||
| DNSSEC validation | — | — | Yes | Yes (RSA, ECDSA, Ed25519) |
|
| Recursive resolver | No | No | Cloud only | Cloud only | From root hints, DNSSEC |
|
||||||
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
| Encrypted upstream (DoH) | No (needs cloudflared) | Yes | Cloud only | Cloud only | Native, single binary |
|
||||||
| Web admin UI | Full | Full | — | Dashboard |
|
| Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary |
|
||||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
| Zero config | Complex | Docker/setup | Yes | Yes | Works out of the box |
|
||||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
| Ad blocking | Yes | Yes | Yes | Limited | 385K+ domains |
|
||||||
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
| Data stays local | Yes | Yes | Cloud | Cloud | 100% local |
|
||||||
|
|
||||||
## Performance
|
## How It Works
|
||||||
|
|
||||||
691ns cached round-trip. ~2.0M qps throughput. Zero heap allocations in the hot path. Recursive queries average 237ms after SRTT warmup (12x improvement over round-robin). ECDSA P-256 DNSSEC verification: 174ns. [Benchmarks →](bench/)
|
```
|
||||||
|
Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Recursive/Forward
|
||||||
|
```
|
||||||
|
|
||||||
## Learn More
|
Two resolution modes: **forward** (relay to upstream like Quad9/Cloudflare) or **recursive** (resolve from root nameservers — no upstream dependency). Set `mode = "recursive"` in `[upstream]` to resolve independently.
|
||||||
|
|
||||||
- [Blog: Implementing DNSSEC from Scratch in Rust](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
No DNS libraries — no `hickory-dns`, no `trust-dns`. The wire protocol — headers, labels, compression pointers, record types — is parsed and serialized by hand. Runs on `tokio` + `axum`, async per-query task spawning.
|
||||||
- [Blog: I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html)
|
|
||||||
- [Configuration reference](numa.toml) — all options documented inline
|
[Configuration reference](numa.toml)
|
||||||
- [REST API](src/api.rs) — 27 endpoints across overrides, cache, blocking, services, diagnostics
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
- [x] DNS forwarding, caching, ad blocking, developer overrides
|
- [x] DNS proxy core — forwarding, caching, local zones
|
||||||
- [x] `.numa` local domains — auto TLS, path routing, WebSocket proxy
|
- [x] Developer overrides — REST API with auto-expiry
|
||||||
- [x] LAN service discovery — mDNS, cross-machine DNS + proxy
|
- [x] Ad blocking — 385K+ domains, live dashboard, allowlist
|
||||||
- [x] DNS-over-HTTPS — encrypted upstream
|
- [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery
|
||||||
- [x] Recursive resolution + DNSSEC — chain-of-trust, NSEC/NSEC3
|
- [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket
|
||||||
- [x] SRTT-based nameserver selection
|
- [x] Path-based routing — URL prefix routing with optional strip, REST API
|
||||||
- [ ] pkarr integration — self-sovereign DNS via Mainline DHT
|
- [x] LAN service discovery — mDNS auto-discovery (opt-in), cross-machine DNS + proxy
|
||||||
- [ ] Global `.numa` names — DHT-backed, no registrar
|
- [x] DNS-over-HTTPS — encrypted upstream via DoH (Quad9, Cloudflare, any provider)
|
||||||
|
- [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
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<string>com.numa.dns</string>
|
<string>com.numa.dns</string>
|
||||||
<key>ProgramArguments</key>
|
<key>ProgramArguments</key>
|
||||||
<array>
|
<array>
|
||||||
<string>{{exe_path}}</string>
|
<string>/usr/local/bin/numa</string>
|
||||||
</array>
|
</array>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
|||||||
10
install.sh
10
install.sh
@@ -70,10 +70,8 @@ echo ""
|
|||||||
echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)"
|
echo " \033[38;2;107;124;78mInstalled:\033[0m $INSTALL_DIR/numa ($TAG)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Get started:"
|
echo " Get started:"
|
||||||
echo " sudo numa install # install service + set as system DNS"
|
echo " sudo numa # start the DNS server"
|
||||||
echo " open http://localhost:5380 # dashboard"
|
echo " sudo numa install # set as system DNS"
|
||||||
echo ""
|
echo " sudo numa service start # run as persistent service"
|
||||||
echo " Other commands:"
|
echo " open http://localhost:5380 # dashboard"
|
||||||
echo " sudo numa # run in foreground (no service)"
|
|
||||||
echo " sudo numa uninstall # restore original DNS"
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Wants=network-online.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart={{exe_path}}
|
ExecStart=/usr/local/bin/numa
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=2
|
RestartSec=2
|
||||||
StandardOutput=journal
|
StandardOutput=journal
|
||||||
|
|||||||
@@ -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; set to "0.0.0.0" for LAN access to .numa services
|
# bind_addr = "127.0.0.1" # default; auto 0.0.0.0 when [lan] enabled
|
||||||
|
|
||||||
# Pre-configured services (numa.numa is always added automatically)
|
# Pre-configured services (numa.numa is always added automatically)
|
||||||
# [[services]]
|
# [[services]]
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ body {
|
|||||||
/* Stat cards row */
|
/* Stat cards row */
|
||||||
.stats-row {
|
.stats-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, 1fr);
|
grid-template-columns: repeat(5, 1fr);
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@@ -125,8 +125,6 @@ body {
|
|||||||
.stat-card.blocked::before { background: var(--rose); }
|
.stat-card.blocked::before { background: var(--rose); }
|
||||||
.stat-card.overrides::before { background: var(--violet); }
|
.stat-card.overrides::before { background: var(--violet); }
|
||||||
.stat-card.uptime::before { background: var(--cyan); }
|
.stat-card.uptime::before { background: var(--cyan); }
|
||||||
.stat-card.memory::before { background: var(--text-dim); }
|
|
||||||
.stat-card.memory .stat-value { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
@@ -470,74 +468,10 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Memory sidebar panel */
|
|
||||||
.memory-bar {
|
|
||||||
display: flex;
|
|
||||||
height: 18px;
|
|
||||||
border-radius: 4px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
margin-bottom: 0.8rem;
|
|
||||||
}
|
|
||||||
.memory-bar-seg {
|
|
||||||
height: 100%;
|
|
||||||
min-width: 2px;
|
|
||||||
transition: width 0.6s ease;
|
|
||||||
}
|
|
||||||
.memory-bar-seg.cache { background: var(--teal); }
|
|
||||||
.memory-bar-seg.blocklist { background: var(--rose); }
|
|
||||||
.memory-bar-seg.querylog { background: var(--amber); }
|
|
||||||
.memory-bar-seg.srtt { background: var(--cyan); }
|
|
||||||
.memory-bar-seg.overrides { background: var(--violet); }
|
|
||||||
.memory-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.3rem 0;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
}
|
|
||||||
.memory-row:last-child { border-bottom: none; }
|
|
||||||
.memory-row-dot {
|
|
||||||
width: 8px;
|
|
||||||
height: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
|
||||||
.memory-row-label {
|
|
||||||
flex: 1;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
.memory-row-size {
|
|
||||||
width: 65px;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--text-primary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.memory-row-entries {
|
|
||||||
width: 90px;
|
|
||||||
text-align: right;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
.memory-rss {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: 0.72rem;
|
|
||||||
color: var(--text-dim);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.main-grid { grid-template-columns: 1fr; }
|
.main-grid { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
|
||||||
.stats-row { grid-template-columns: repeat(3, 1fr); }
|
|
||||||
}
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
.stats-row { grid-template-columns: repeat(2, 1fr); }
|
||||||
.dashboard { padding: 1rem; }
|
.dashboard { padding: 1rem; }
|
||||||
@@ -590,11 +524,6 @@ 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 -->
|
||||||
@@ -719,17 +648,6 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Memory breakdown -->
|
|
||||||
<div class="panel" id="memoryPanel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<span class="panel-title">Memory</span>
|
|
||||||
<span class="panel-title" id="memoryTotal" style="color: var(--text-dim)"></span>
|
|
||||||
</div>
|
|
||||||
<div class="panel-body" id="memoryBody">
|
|
||||||
<div class="empty-state">No memory data</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Cache entries -->
|
<!-- Cache entries -->
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -794,69 +712,6 @@ function formatRemaining(secs) {
|
|||||||
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`;
|
return `${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m left`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
return (bytes / 1073741824).toFixed(1) + ' GB';
|
|
||||||
}
|
|
||||||
|
|
||||||
const MEMORY_COMPONENTS = [
|
|
||||||
{ key: 'cache', label: 'Cache', cls: 'cache', color: 'var(--teal)' },
|
|
||||||
{ key: 'blocklist', label: 'Blocklist', cls: 'blocklist', color: 'var(--rose)' },
|
|
||||||
{ key: 'query_log', label: 'Query Log', cls: 'querylog', color: 'var(--amber)' },
|
|
||||||
{ key: 'srtt', label: 'SRTT', cls: 'srtt', color: 'var(--cyan)' },
|
|
||||||
{ key: 'overrides', label: 'Overrides', cls: 'overrides', color: 'var(--violet)' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderMemory(mem, stats) {
|
|
||||||
if (!mem) return;
|
|
||||||
|
|
||||||
// Stat card
|
|
||||||
document.getElementById('memoryRss').textContent = formatBytes(mem.process_memory_bytes);
|
|
||||||
document.getElementById('memorySub').textContent = 'est. ' + formatBytes(mem.total_estimated_bytes);
|
|
||||||
|
|
||||||
const entryCounts = {
|
|
||||||
cache: stats.cache.entries,
|
|
||||||
blocklist: stats.blocking.domains_loaded,
|
|
||||||
query_log: mem.query_log_entries,
|
|
||||||
srtt: mem.srtt_entries,
|
|
||||||
overrides: stats.overrides.active,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sidebar panel
|
|
||||||
const total = mem.total_estimated_bytes || 1;
|
|
||||||
document.getElementById('memoryTotal').textContent = formatBytes(total);
|
|
||||||
|
|
||||||
const barSegments = MEMORY_COMPONENTS.map(c => {
|
|
||||||
const bytes = mem[c.key + '_bytes'] || 0;
|
|
||||||
const pct = ((bytes / total) * 100).toFixed(1);
|
|
||||||
return `<div class="memory-bar-seg ${c.cls}" style="width:${pct}%" title="${c.label}: ${formatBytes(bytes)} (${pct}%)"></div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
const rows = MEMORY_COMPONENTS.map(c => {
|
|
||||||
const bytes = mem[c.key + '_bytes'] || 0;
|
|
||||||
const entries = entryCounts[c.key] || 0;
|
|
||||||
return `
|
|
||||||
<div class="memory-row">
|
|
||||||
<div class="memory-row-dot" style="background:${c.color}"></div>
|
|
||||||
<span class="memory-row-label">${c.label}</span>
|
|
||||||
<span class="memory-row-size">${formatBytes(bytes)}</span>
|
|
||||||
<span class="memory-row-entries">${formatNumber(entries)} entries</span>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
document.getElementById('memoryBody').innerHTML = `
|
|
||||||
<div class="memory-bar">${barSegments}</div>
|
|
||||||
${rows}
|
|
||||||
<div class="memory-rss">
|
|
||||||
<span>Process Footprint</span>
|
|
||||||
<span>${formatBytes(mem.process_memory_bytes)}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PATH_DEFS = [
|
const PATH_DEFS = [
|
||||||
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
|
{ key: 'forwarded', label: 'Forward', cls: 'forward' },
|
||||||
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
|
{ key: 'recursive', label: 'Recursive', cls: 'recursive' },
|
||||||
@@ -1027,9 +882,6 @@ async function refresh() {
|
|||||||
document.getElementById('footerUpstream').textContent = stats.upstream || '';
|
document.getElementById('footerUpstream').textContent = stats.upstream || '';
|
||||||
document.getElementById('footerConfig').textContent = stats.config_path || '';
|
document.getElementById('footerConfig').textContent = stats.config_path || '';
|
||||||
document.getElementById('footerData').textContent = stats.data_dir || '';
|
document.getElementById('footerData').textContent = stats.data_dir || '';
|
||||||
const modeEl = document.getElementById('footerMode');
|
|
||||||
modeEl.textContent = stats.mode || '—';
|
|
||||||
modeEl.style.color = stats.mode === 'recursive' ? 'var(--emerald)' : 'var(--amber)';
|
|
||||||
document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off';
|
document.getElementById('footerDnssec').textContent = stats.dnssec ? 'on' : 'off';
|
||||||
document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
|
document.getElementById('footerDnssec').style.color = stats.dnssec ? 'var(--emerald)' : 'var(--text-dim)';
|
||||||
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
|
document.getElementById('footerSrtt').textContent = stats.srtt ? 'on' : 'off';
|
||||||
@@ -1093,7 +945,7 @@ async function refresh() {
|
|||||||
prevTime = now;
|
prevTime = now;
|
||||||
|
|
||||||
// Cache hit rate
|
// Cache hit rate
|
||||||
const answered = q.cached + q.forwarded + q.recursive + q.coalesced + q.local + q.overridden;
|
const answered = q.cached + q.forwarded + q.local + q.overridden;
|
||||||
const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0';
|
const hitRate = answered > 0 ? ((q.cached / answered) * 100).toFixed(1) : '0.0';
|
||||||
document.getElementById('cacheRate').textContent = hitRate + '%';
|
document.getElementById('cacheRate').textContent = hitRate + '%';
|
||||||
|
|
||||||
@@ -1105,7 +957,6 @@ async function refresh() {
|
|||||||
renderServices(services);
|
renderServices(services);
|
||||||
renderBlockingInfo(blockingInfo);
|
renderBlockingInfo(blockingInfo);
|
||||||
renderAllowlist(allowlist);
|
renderAllowlist(allowlist);
|
||||||
renderMemory(stats.memory, stats);
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('statusDot').className = 'status-dot error';
|
document.getElementById('statusDot').className = 'status-dot error';
|
||||||
@@ -1385,7 +1236,6 @@ setInterval(refresh, 2000);
|
|||||||
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
|
Config: <span id="footerConfig" style="user-select:all;color:var(--emerald);"></span>
|
||||||
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
|
· Data: <span id="footerData" style="user-select:all;color:var(--emerald);"></span>
|
||||||
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
|
· Upstream: <span id="footerUpstream" style="user-select:all;color:var(--emerald);"></span>
|
||||||
· Mode: <span id="footerMode" style="color:var(--text-dim);">—</span>
|
|
||||||
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);">—</span>
|
· DNSSEC: <span id="footerDnssec" style="color:var(--text-dim);">—</span>
|
||||||
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);">—</span>
|
· SRTT: <span id="footerSrtt" style="color:var(--text-dim);">—</span>
|
||||||
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
|
· Logs: <span style="user-select:all;color:var(--emerald);">macOS: /usr/local/var/log/numa.log · Linux: journalctl -u numa -f</span>
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Numa — DNS you own. Everywhere you go.</title>
|
<title>Numa — DNS you own. Everywhere you go.</title>
|
||||||
<meta name="description" content="DNS you own. Portable DNS resolver with caching, ad blocking, .numa local domains, developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust.">
|
<meta name="description" content="DNS you own. Recursive resolver with full DNSSEC validation, ad blocking, .numa local domains, developer overrides. A single portable binary built from scratch in Rust.">
|
||||||
<link rel="canonical" href="https://numa.rs">
|
<link rel="canonical" href="https://numa.rs">
|
||||||
<meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
|
<meta property="og:title" content="Numa — DNS you own. Everywhere you go.">
|
||||||
<meta property="og:description" content="Portable DNS resolver with caching, ad blocking, .numa local domains, and developer overrides. Optional recursive resolution with full DNSSEC validation. Built from scratch in Rust.">
|
<meta property="og:description" content="Recursive DNS resolver with full DNSSEC validation, ad blocking, .numa local domains, and developer overrides. Built from scratch in Rust.">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:url" content="https://numa.rs">
|
<meta property="og:url" content="https://numa.rs">
|
||||||
<link rel="stylesheet" href="/fonts/fonts.css">
|
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||||
@@ -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 DNS resolver with caching, ad blocking, local service domains, and a REST API. Optional recursive resolution with DNSSEC. Everything runs in a single binary.</p>
|
<p class="lead">A recursive DNS resolver with DNSSEC validation, ad blocking, local service domains, and a REST API. Everything runs in a single binary.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="layers-grid">
|
<div class="layers-grid">
|
||||||
<div class="layer-card reveal reveal-delay-1">
|
<div class="layer-card reveal reveal-delay-1">
|
||||||
<div class="layer-badge">Layer 1</div>
|
<div class="layer-badge">Layer 1</div>
|
||||||
<h3>Resolve & Protect</h3>
|
<h3>Resolve & Protect</h3>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Forward mode by default — transparent proxy to your existing DNS, with caching</li>
|
<li>Recursive resolution — resolve from root nameservers, no upstream needed</li>
|
||||||
<li>Ad & tracker blocking — 385K+ domains, zero config</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>DNSSEC validation — chain-of-trust + NSEC/NSEC3 denial proofs (RSA, ECDSA, Ed25519)</li>
|
||||||
|
<li>Ad & tracker blocking — 385K+ domains, zero config</li>
|
||||||
|
<li>DNS-over-HTTPS — encrypted upstream as alternative to recursive mode</li>
|
||||||
<li>TTL-aware caching (sub-ms lookups)</li>
|
<li>TTL-aware caching (sub-ms lookups)</li>
|
||||||
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
<li>Single binary, portable — macOS, Linux, and Windows</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
62
src/api.rs
62
src/api.rs
@@ -160,7 +160,6 @@ struct QueryLogResponse {
|
|||||||
struct StatsResponse {
|
struct StatsResponse {
|
||||||
uptime_secs: u64,
|
uptime_secs: u64,
|
||||||
upstream: String,
|
upstream: String,
|
||||||
mode: &'static str, // "recursive" or "forward" — never "auto" at runtime
|
|
||||||
config_path: String,
|
config_path: String,
|
||||||
data_dir: String,
|
data_dir: String,
|
||||||
dnssec: bool,
|
dnssec: bool,
|
||||||
@@ -170,7 +169,6 @@ struct StatsResponse {
|
|||||||
overrides: OverrideStats,
|
overrides: OverrideStats,
|
||||||
blocking: BlockingStatsResponse,
|
blocking: BlockingStatsResponse,
|
||||||
lan: LanStatsResponse,
|
lan: LanStatsResponse,
|
||||||
memory: MemoryStats,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -211,19 +209,6 @@ struct BlockingStatsResponse {
|
|||||||
allowlist_size: usize,
|
allowlist_size: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
struct MemoryStats {
|
|
||||||
cache_bytes: usize,
|
|
||||||
blocklist_bytes: usize,
|
|
||||||
query_log_bytes: usize,
|
|
||||||
query_log_entries: usize,
|
|
||||||
srtt_bytes: usize,
|
|
||||||
srtt_entries: usize,
|
|
||||||
overrides_bytes: usize,
|
|
||||||
total_estimated_bytes: usize,
|
|
||||||
process_memory_bytes: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct DiagnoseResponse {
|
struct DiagnoseResponse {
|
||||||
domain: String,
|
domain: String,
|
||||||
@@ -425,8 +410,14 @@ async fn forward_query_for_diagnose(
|
|||||||
timeout: std::time::Duration,
|
timeout: std::time::Duration,
|
||||||
) -> (bool, String) {
|
) -> (bool, String) {
|
||||||
use crate::packet::DnsPacket;
|
use crate::packet::DnsPacket;
|
||||||
|
use crate::question::DnsQuestion;
|
||||||
|
|
||||||
let query = DnsPacket::query(0xBEEF, domain, QueryType::A);
|
let mut query = DnsPacket::new();
|
||||||
|
query.header.id = 0xBEEF;
|
||||||
|
query.header.recursion_desired = true;
|
||||||
|
query
|
||||||
|
.questions
|
||||||
|
.push(DnsQuestion::new(domain.to_string(), QueryType::A));
|
||||||
|
|
||||||
match forward_query(&query, upstream, timeout).await {
|
match forward_query(&query, upstream, timeout).await {
|
||||||
Ok(resp) => (
|
Ok(resp) => (
|
||||||
@@ -485,29 +476,12 @@ async fn query_log(
|
|||||||
|
|
||||||
async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
||||||
let snap = ctx.stats.lock().unwrap().snapshot();
|
let snap = ctx.stats.lock().unwrap().snapshot();
|
||||||
let (cache_len, cache_max, cache_bytes) = {
|
let (cache_len, cache_max) = {
|
||||||
let cache = ctx.cache.read().unwrap();
|
let cache = ctx.cache.read().unwrap();
|
||||||
(cache.len(), cache.max_entries(), cache.heap_bytes())
|
(cache.len(), cache.max_entries())
|
||||||
};
|
};
|
||||||
let (override_count, overrides_bytes) = {
|
let override_count = ctx.overrides.read().unwrap().active_count();
|
||||||
let ov = ctx.overrides.read().unwrap();
|
let bl_stats = ctx.blocklist.read().unwrap().stats();
|
||||||
(ov.active_count(), ov.heap_bytes())
|
|
||||||
};
|
|
||||||
let (bl_stats, blocklist_bytes) = {
|
|
||||||
let bl = ctx.blocklist.read().unwrap();
|
|
||||||
(bl.stats(), bl.heap_bytes())
|
|
||||||
};
|
|
||||||
let (query_log_bytes, query_log_entries) = {
|
|
||||||
let log = ctx.query_log.lock().unwrap();
|
|
||||||
(log.heap_bytes(), log.len())
|
|
||||||
};
|
|
||||||
let (srtt_bytes, srtt_entries, srtt_enabled) = {
|
|
||||||
let s = ctx.srtt.read().unwrap();
|
|
||||||
(s.heap_bytes(), s.len(), s.is_enabled())
|
|
||||||
};
|
|
||||||
|
|
||||||
let total_estimated =
|
|
||||||
cache_bytes + blocklist_bytes + query_log_bytes + srtt_bytes + overrides_bytes;
|
|
||||||
|
|
||||||
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
let upstream = if ctx.upstream_mode == crate::config::UpstreamMode::Recursive {
|
||||||
"recursive (root hints)".to_string()
|
"recursive (root hints)".to_string()
|
||||||
@@ -518,11 +492,10 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
|||||||
Json(StatsResponse {
|
Json(StatsResponse {
|
||||||
uptime_secs: snap.uptime_secs,
|
uptime_secs: snap.uptime_secs,
|
||||||
upstream,
|
upstream,
|
||||||
mode: ctx.upstream_mode.as_str(),
|
|
||||||
config_path: ctx.config_path.clone(),
|
config_path: ctx.config_path.clone(),
|
||||||
data_dir: ctx.data_dir.to_string_lossy().to_string(),
|
data_dir: ctx.data_dir.to_string_lossy().to_string(),
|
||||||
dnssec: ctx.dnssec_enabled,
|
dnssec: ctx.dnssec_enabled,
|
||||||
srtt: srtt_enabled,
|
srtt: ctx.srtt.read().unwrap().is_enabled(),
|
||||||
queries: QueriesStats {
|
queries: QueriesStats {
|
||||||
total: snap.total,
|
total: snap.total,
|
||||||
forwarded: snap.forwarded,
|
forwarded: snap.forwarded,
|
||||||
@@ -551,17 +524,6 @@ async fn stats(State(ctx): State<Arc<ServerCtx>>) -> Json<StatsResponse> {
|
|||||||
enabled: ctx.lan_enabled,
|
enabled: ctx.lan_enabled,
|
||||||
peers: ctx.lan_peers.lock().unwrap().list().len(),
|
peers: ctx.lan_peers.lock().unwrap().list().len(),
|
||||||
},
|
},
|
||||||
memory: MemoryStats {
|
|
||||||
cache_bytes,
|
|
||||||
blocklist_bytes,
|
|
||||||
query_log_bytes,
|
|
||||||
query_log_entries,
|
|
||||||
srtt_bytes,
|
|
||||||
srtt_entries,
|
|
||||||
overrides_bytes,
|
|
||||||
total_estimated_bytes: total_estimated,
|
|
||||||
process_memory_bytes: crate::stats::process_memory_bytes(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,15 +183,6 @@ 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(),
|
||||||
@@ -243,23 +234,6 @@ 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,26 +142,6 @@ impl DnsCache {
|
|||||||
self.entry_count = 0;
|
self.entry_count = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn heap_bytes(&self) -> usize {
|
|
||||||
let outer_slot = std::mem::size_of::<u64>()
|
|
||||||
+ std::mem::size_of::<String>()
|
|
||||||
+ std::mem::size_of::<HashMap<QueryType, CacheEntry>>()
|
|
||||||
+ 1;
|
|
||||||
let mut total = self.entries.capacity() * outer_slot;
|
|
||||||
for (domain, type_map) in &self.entries {
|
|
||||||
total += domain.capacity();
|
|
||||||
let inner_slot = std::mem::size_of::<u64>()
|
|
||||||
+ std::mem::size_of::<QueryType>()
|
|
||||||
+ std::mem::size_of::<CacheEntry>()
|
|
||||||
+ 1;
|
|
||||||
total += type_map.capacity() * inner_slot;
|
|
||||||
for entry in type_map.values() {
|
|
||||||
total += entry.packet.heap_bytes();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
total
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, domain: &str) {
|
pub fn remove(&mut self, domain: &str) {
|
||||||
let domain_lower = domain.to_lowercase();
|
let domain_lower = domain.to_lowercase();
|
||||||
if let Some(type_map) = self.entries.remove(&domain_lower) {
|
if let Some(type_map) = self.entries.remove(&domain_lower) {
|
||||||
@@ -214,23 +194,3 @@ fn adjust_ttls(records: &mut [DnsRecord], new_ttl: u32) {
|
|||||||
record.set_ttl(new_ttl);
|
record.set_ttl(new_ttl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::packet::DnsPacket;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heap_bytes_grows_with_entries() {
|
|
||||||
let mut cache = DnsCache::new(100, 1, 3600);
|
|
||||||
let empty = cache.heap_bytes();
|
|
||||||
let mut pkt = DnsPacket::new();
|
|
||||||
pkt.answers.push(DnsRecord::A {
|
|
||||||
domain: "example.com".into(),
|
|
||||||
addr: "1.2.3.4".parse().unwrap(),
|
|
||||||
ttl: 300,
|
|
||||||
});
|
|
||||||
cache.insert("example.com", QueryType::A, &pkt);
|
|
||||||
assert!(cache.heap_bytes() > empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -59,31 +59,18 @@ fn default_bind_addr() -> String {
|
|||||||
"0.0.0.0:53".to_string()
|
"0.0.0.0:53".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const DEFAULT_API_PORT: u16 = 5380;
|
|
||||||
|
|
||||||
fn default_api_port() -> u16 {
|
fn default_api_port() -> u16 {
|
||||||
DEFAULT_API_PORT
|
5380
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
|
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Copy)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum UpstreamMode {
|
pub enum UpstreamMode {
|
||||||
Auto,
|
|
||||||
#[default]
|
#[default]
|
||||||
Forward,
|
Forward,
|
||||||
Recursive,
|
Recursive,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpstreamMode {
|
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
UpstreamMode::Auto => "auto",
|
|
||||||
UpstreamMode::Forward => "forward",
|
|
||||||
UpstreamMode::Recursive => "recursive",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct UpstreamConfig {
|
pub struct UpstreamConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -116,12 +103,8 @@ impl Default for UpstreamConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_srtt() -> bool {
|
fn default_srtt() -> bool {
|
||||||
default_true()
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_prime_tlds() -> Vec<String> {
|
fn default_prime_tlds() -> Vec<String> {
|
||||||
|
|||||||
589
src/ctx.rs
589
src/ctx.rs
@@ -93,13 +93,18 @@ 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);
|
||||||
resp.answers.push(sinkhole_record(
|
match qtype {
|
||||||
&qname,
|
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||||
qtype,
|
domain: qname.clone(),
|
||||||
std::net::Ipv4Addr::LOCALHOST,
|
addr: std::net::Ipv6Addr::LOCALHOST,
|
||||||
std::net::Ipv6Addr::LOCALHOST,
|
ttl: 300,
|
||||||
300,
|
}),
|
||||||
));
|
_ => resp.answers.push(DnsRecord::A {
|
||||||
|
domain: qname.clone(),
|
||||||
|
addr: std::net::Ipv4Addr::LOCALHOST,
|
||||||
|
ttl: 300,
|
||||||
|
}),
|
||||||
|
}
|
||||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||||
} else if is_special_use_domain(&qname) {
|
} else if is_special_use_domain(&qname) {
|
||||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||||
@@ -108,17 +113,12 @@ 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: remote clients get LAN IP (can't reach 127.0.0.1), local get loopback
|
// Resolve .numa: local services → 127.0.0.1, LAN peers → peer IP
|
||||||
let service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname);
|
let service_name = qname.strip_suffix(&ctx.proxy_tld_suffix).unwrap_or(&qname);
|
||||||
let is_remote = !src_addr.ip().is_loopback();
|
|
||||||
let resolve_ip = {
|
let resolve_ip = {
|
||||||
let local = ctx.services.lock().unwrap();
|
let local = ctx.services.lock().unwrap();
|
||||||
if local.lookup(service_name).is_some() {
|
if local.lookup(service_name).is_some() {
|
||||||
if is_remote {
|
std::net::Ipv4Addr::LOCALHOST
|
||||||
*ctx.lan_ip.lock().unwrap()
|
|
||||||
} else {
|
|
||||||
std::net::Ipv4Addr::LOCALHOST
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
let mut peers = ctx.lan_peers.lock().unwrap();
|
let mut peers = ctx.lan_peers.lock().unwrap();
|
||||||
peers
|
peers
|
||||||
@@ -130,24 +130,38 @@ pub async fn handle_query(
|
|||||||
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let v6 = if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
|
|
||||||
std::net::Ipv6Addr::LOCALHOST
|
|
||||||
} else {
|
|
||||||
resolve_ip.to_ipv6_mapped()
|
|
||||||
};
|
|
||||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
resp.answers
|
match qtype {
|
||||||
.push(sinkhole_record(&qname, qtype, resolve_ip, v6, 300));
|
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||||
|
domain: qname.clone(),
|
||||||
|
addr: if resolve_ip == std::net::Ipv4Addr::LOCALHOST {
|
||||||
|
std::net::Ipv6Addr::LOCALHOST
|
||||||
|
} else {
|
||||||
|
resolve_ip.to_ipv6_mapped()
|
||||||
|
},
|
||||||
|
ttl: 300,
|
||||||
|
}),
|
||||||
|
_ => resp.answers.push(DnsRecord::A {
|
||||||
|
domain: qname.clone(),
|
||||||
|
addr: resolve_ip,
|
||||||
|
ttl: 300,
|
||||||
|
}),
|
||||||
|
}
|
||||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||||
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
|
} else if ctx.blocklist.read().unwrap().is_blocked(&qname) {
|
||||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
resp.answers.push(sinkhole_record(
|
match qtype {
|
||||||
&qname,
|
QueryType::AAAA => resp.answers.push(DnsRecord::AAAA {
|
||||||
qtype,
|
domain: qname.clone(),
|
||||||
std::net::Ipv4Addr::UNSPECIFIED,
|
addr: std::net::Ipv6Addr::UNSPECIFIED,
|
||||||
std::net::Ipv6Addr::UNSPECIFIED,
|
ttl: 60,
|
||||||
60,
|
}),
|
||||||
));
|
_ => resp.answers.push(DnsRecord::A {
|
||||||
|
domain: qname.clone(),
|
||||||
|
addr: std::net::Ipv4Addr::UNSPECIFIED,
|
||||||
|
ttl: 60,
|
||||||
|
}),
|
||||||
|
}
|
||||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||||
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
||||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
@@ -162,54 +176,64 @@ 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 let Some(fwd_addr) =
|
|
||||||
crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules)
|
|
||||||
{
|
|
||||||
// Conditional forwarding takes priority over recursive mode
|
|
||||||
// (e.g. Tailscale .ts.net, VPC private zones)
|
|
||||||
let upstream = Upstream::Udp(fwd_addr);
|
|
||||||
match forward_query(&query, &upstream, ctx.timeout).await {
|
|
||||||
Ok(resp) => {
|
|
||||||
ctx.cache.write().unwrap().insert(&qname, qtype, &resp);
|
|
||||||
(resp, QueryPath::Forwarded, DnssecStatus::Indeterminate)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
error!(
|
|
||||||
"{} | {:?} {} | FORWARD ERROR | {}",
|
|
||||||
src_addr, qtype, qname, e
|
|
||||||
);
|
|
||||||
(
|
|
||||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
|
||||||
QueryPath::UpstreamError,
|
|
||||||
DnssecStatus::Indeterminate,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||||
let key = (qname.clone(), qtype);
|
let key = (qname.clone(), qtype);
|
||||||
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
|
let disposition = acquire_inflight(&ctx.inflight, key.clone());
|
||||||
crate::recursive::resolve_recursive(
|
|
||||||
&qname,
|
match disposition {
|
||||||
qtype,
|
Disposition::Follower(mut rx) => {
|
||||||
&ctx.cache,
|
debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname);
|
||||||
&query,
|
match rx.recv().await {
|
||||||
&ctx.root_hints,
|
Ok(Some(mut resp)) => {
|
||||||
&ctx.srtt,
|
resp.header.id = query.header.id;
|
||||||
)
|
(resp, QueryPath::Coalesced, DnssecStatus::Indeterminate)
|
||||||
})
|
}
|
||||||
.await;
|
_ => (
|
||||||
if path == QueryPath::Coalesced {
|
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||||
debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname);
|
QueryPath::UpstreamError,
|
||||||
} else if path == QueryPath::UpstreamError {
|
DnssecStatus::Indeterminate,
|
||||||
error!(
|
),
|
||||||
"{} | {:?} {} | RECURSIVE ERROR | {}",
|
}
|
||||||
src_addr,
|
}
|
||||||
qtype,
|
Disposition::Leader(tx) => {
|
||||||
qname,
|
// Drop guard: remove inflight entry even on panic/cancellation
|
||||||
err.as_deref().unwrap_or("leader failed")
|
let guard = InflightGuard {
|
||||||
);
|
inflight: &ctx.inflight,
|
||||||
|
key: key.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = crate::recursive::resolve_recursive(
|
||||||
|
&qname,
|
||||||
|
qtype,
|
||||||
|
&ctx.cache,
|
||||||
|
&query,
|
||||||
|
&ctx.root_hints,
|
||||||
|
&ctx.srtt,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(resp) => {
|
||||||
|
let _ = tx.send(Some(resp.clone()));
|
||||||
|
(resp, QueryPath::Recursive, DnssecStatus::Indeterminate)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = tx.send(None);
|
||||||
|
error!(
|
||||||
|
"{} | {:?} {} | RECURSIVE ERROR | {}",
|
||||||
|
src_addr, qtype, qname, e
|
||||||
|
);
|
||||||
|
(
|
||||||
|
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||||
|
QueryPath::UpstreamError,
|
||||||
|
DnssecStatus::Indeterminate,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
(resp, path, DnssecStatus::Indeterminate)
|
|
||||||
} else {
|
} else {
|
||||||
let upstream =
|
let upstream =
|
||||||
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
match crate::system_dns::match_forwarding_rule(&qname, &ctx.forwarding_rules) {
|
||||||
@@ -392,27 +416,6 @@ fn is_special_use_domain(qname: &str) -> bool {
|
|||||||
qname == "local" || qname.ends_with(".local")
|
qname == "local" || qname.ends_with(".local")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sinkhole_record(
|
|
||||||
domain: &str,
|
|
||||||
qtype: QueryType,
|
|
||||||
v4: std::net::Ipv4Addr,
|
|
||||||
v6: std::net::Ipv6Addr,
|
|
||||||
ttl: u32,
|
|
||||||
) -> DnsRecord {
|
|
||||||
match qtype {
|
|
||||||
QueryType::AAAA => DnsRecord::AAAA {
|
|
||||||
domain: domain.to_string(),
|
|
||||||
addr: v6,
|
|
||||||
ttl,
|
|
||||||
},
|
|
||||||
_ => DnsRecord::A {
|
|
||||||
domain: domain.to_string(),
|
|
||||||
addr: v4,
|
|
||||||
ttl,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Disposition {
|
enum Disposition {
|
||||||
Leader(broadcast::Sender<Option<DnsPacket>>),
|
Leader(broadcast::Sender<Option<DnsPacket>>),
|
||||||
Follower(broadcast::Receiver<Option<DnsPacket>>),
|
Follower(broadcast::Receiver<Option<DnsPacket>>),
|
||||||
@@ -429,57 +432,6 @@ fn acquire_inflight(inflight: &Mutex<InflightMap>, key: (String, QueryType)) ->
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a resolve function with in-flight coalescing. Multiple concurrent calls
|
|
||||||
/// for the same key share a single resolution — the first caller (leader)
|
|
||||||
/// executes `resolve_fn`, and followers wait for the broadcast result.
|
|
||||||
async fn resolve_coalesced<F, Fut>(
|
|
||||||
inflight: &Mutex<InflightMap>,
|
|
||||||
key: (String, QueryType),
|
|
||||||
query: &DnsPacket,
|
|
||||||
resolve_fn: F,
|
|
||||||
) -> (DnsPacket, QueryPath, Option<String>)
|
|
||||||
where
|
|
||||||
F: FnOnce() -> Fut,
|
|
||||||
Fut: std::future::Future<Output = crate::Result<DnsPacket>>,
|
|
||||||
{
|
|
||||||
let disposition = acquire_inflight(inflight, key.clone());
|
|
||||||
|
|
||||||
match disposition {
|
|
||||||
Disposition::Follower(mut rx) => match rx.recv().await {
|
|
||||||
Ok(Some(mut resp)) => {
|
|
||||||
resp.header.id = query.header.id;
|
|
||||||
(resp, QueryPath::Coalesced, None)
|
|
||||||
}
|
|
||||||
_ => (
|
|
||||||
DnsPacket::response_from(query, ResultCode::SERVFAIL),
|
|
||||||
QueryPath::UpstreamError,
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
Disposition::Leader(tx) => {
|
|
||||||
let guard = InflightGuard { inflight, key };
|
|
||||||
let result = resolve_fn().await;
|
|
||||||
drop(guard);
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(resp) => {
|
|
||||||
let _ = tx.send(Some(resp.clone()));
|
|
||||||
(resp, QueryPath::Recursive, None)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = tx.send(None);
|
|
||||||
let err_msg = e.to_string();
|
|
||||||
(
|
|
||||||
DnsPacket::response_from(query, ResultCode::SERVFAIL),
|
|
||||||
QueryPath::UpstreamError,
|
|
||||||
Some(err_msg),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct InflightGuard<'a> {
|
struct InflightGuard<'a> {
|
||||||
inflight: &'a Mutex<InflightMap>,
|
inflight: &'a Mutex<InflightMap>,
|
||||||
key: (String, QueryType),
|
key: (String, QueryType),
|
||||||
@@ -491,6 +443,20 @@ impl Drop for InflightGuard<'_> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a wire-format DNS query packet for the given domain and type.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn build_wire_query(id: u16, domain: &str, qtype: QueryType) -> BytePacketBuffer {
|
||||||
|
let mut pkt = DnsPacket::new();
|
||||||
|
pkt.header.id = id;
|
||||||
|
pkt.header.recursion_desired = true;
|
||||||
|
pkt.header.questions = 1;
|
||||||
|
pkt.questions
|
||||||
|
.push(crate::question::DnsQuestion::new(domain.to_string(), qtype));
|
||||||
|
let mut buf = BytePacketBuffer::new();
|
||||||
|
pkt.write(&mut buf).unwrap();
|
||||||
|
BytePacketBuffer::from_bytes(buf.filled())
|
||||||
|
}
|
||||||
|
|
||||||
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
|
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
|
||||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||||
if qname == "ipv4only.arpa" {
|
if qname == "ipv4only.arpa" {
|
||||||
@@ -529,8 +495,8 @@ fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> Dns
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::Ipv4Addr;
|
use std::net::{Ipv4Addr, SocketAddr};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
// ---- InflightGuard unit tests ----
|
// ---- InflightGuard unit tests ----
|
||||||
@@ -703,212 +669,189 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Integration: resolve_coalesced with mock futures ----
|
// ---- Integration: concurrent handle_query coalescing ----
|
||||||
|
|
||||||
fn mock_response(domain: &str) -> DnsPacket {
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
let mut resp = DnsPacket::new();
|
use tokio::net::TcpListener;
|
||||||
resp.header.response = true;
|
|
||||||
resp.header.rescode = ResultCode::NOERROR;
|
/// Spawn a slow TCP DNS server that delays `delay` before responding.
|
||||||
resp.answers.push(DnsRecord::A {
|
/// Returns (addr, query_count) where query_count is an Arc<AtomicU32>
|
||||||
domain: domain.to_string(),
|
/// tracking how many queries were actually resolved (not coalesced).
|
||||||
addr: Ipv4Addr::new(10, 0, 0, 1),
|
async fn spawn_slow_dns_server(
|
||||||
ttl: 300,
|
delay: Duration,
|
||||||
|
) -> (SocketAddr, Arc<std::sync::atomic::AtomicU32>) {
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
||||||
|
let count_clone = count.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let (mut stream, _) = match listener.accept().await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => break,
|
||||||
|
};
|
||||||
|
let count = count_clone.clone();
|
||||||
|
let delay = delay;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut len_buf = [0u8; 2];
|
||||||
|
if stream.read_exact(&mut len_buf).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let len = u16::from_be_bytes(len_buf) as usize;
|
||||||
|
let mut data = vec![0u8; len];
|
||||||
|
if stream.read_exact(&mut data).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut buf = BytePacketBuffer::from_bytes(&data);
|
||||||
|
let query = match DnsPacket::from_buffer(&mut buf) {
|
||||||
|
Ok(q) => q,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
|
||||||
|
// Deliberate delay to create coalescing window
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
|
||||||
|
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||||
|
resp.header.authoritative_answer = true;
|
||||||
|
if let Some(q) = query.questions.first() {
|
||||||
|
resp.answers.push(DnsRecord::A {
|
||||||
|
domain: q.name.clone(),
|
||||||
|
addr: Ipv4Addr::new(10, 0, 0, 1),
|
||||||
|
ttl: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut resp_buf = BytePacketBuffer::new();
|
||||||
|
if resp.write(&mut resp_buf).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let resp_bytes = resp_buf.filled();
|
||||||
|
let mut out = Vec::with_capacity(2 + resp_bytes.len());
|
||||||
|
out.extend_from_slice(&(resp_bytes.len() as u16).to_be_bytes());
|
||||||
|
out.extend_from_slice(resp_bytes);
|
||||||
|
let _ = stream.write_all(&out).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
resp
|
(addr, count)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn test_recursive_ctx(root_hint: SocketAddr) -> Arc<ServerCtx> {
|
||||||
|
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
Arc::new(ServerCtx {
|
||||||
|
socket,
|
||||||
|
zone_map: HashMap::new(),
|
||||||
|
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||||
|
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||||
|
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||||
|
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||||
|
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
||||||
|
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||||
|
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||||
|
forwarding_rules: Vec::new(),
|
||||||
|
upstream: Mutex::new(crate::forward::Upstream::Udp(
|
||||||
|
"127.0.0.1:53".parse().unwrap(),
|
||||||
|
)),
|
||||||
|
upstream_auto: false,
|
||||||
|
upstream_port: 53,
|
||||||
|
lan_ip: Mutex::new(Ipv4Addr::LOCALHOST),
|
||||||
|
timeout: Duration::from_secs(3),
|
||||||
|
proxy_tld: "numa".to_string(),
|
||||||
|
proxy_tld_suffix: ".numa".to_string(),
|
||||||
|
lan_enabled: false,
|
||||||
|
config_path: "/tmp/test-numa.toml".to_string(),
|
||||||
|
config_found: false,
|
||||||
|
config_dir: std::path::PathBuf::from("/tmp"),
|
||||||
|
data_dir: std::path::PathBuf::from("/tmp"),
|
||||||
|
tls_config: None,
|
||||||
|
upstream_mode: crate::config::UpstreamMode::Recursive,
|
||||||
|
root_hints: vec![root_hint],
|
||||||
|
srtt: RwLock::new(crate::srtt::SrttCache::new(true)),
|
||||||
|
inflight: Mutex::new(HashMap::new()),
|
||||||
|
dnssec_enabled: false,
|
||||||
|
dnssec_strict: false,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn concurrent_queries_coalesce_to_single_resolution() {
|
async fn concurrent_queries_coalesce_to_single_resolution() {
|
||||||
let inflight = Arc::new(Mutex::new(HashMap::new()));
|
// Force TCP-only so mock server works
|
||||||
let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
crate::recursive::UDP_DISABLED.store(true, std::sync::atomic::Ordering::Release);
|
||||||
|
|
||||||
|
let (server_addr, query_count) = spawn_slow_dns_server(Duration::from_millis(200)).await;
|
||||||
|
let ctx = test_recursive_ctx(server_addr).await;
|
||||||
|
let src: SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||||
|
|
||||||
|
// Fire 5 concurrent queries for the same (domain, A)
|
||||||
let mut handles = Vec::new();
|
let mut handles = Vec::new();
|
||||||
for i in 0..5u16 {
|
for i in 0..5u16 {
|
||||||
let count = resolve_count.clone();
|
let ctx = ctx.clone();
|
||||||
let inf = inflight.clone();
|
let buf = build_wire_query(100 + i, "coalesce-test.example.com", QueryType::A);
|
||||||
let key = ("coalesce.test".to_string(), QueryType::A);
|
handles.push(tokio::spawn(
|
||||||
let query = DnsPacket::query(100 + i, "coalesce.test", QueryType::A);
|
async move { handle_query(buf, src, &ctx).await },
|
||||||
handles.push(tokio::spawn(async move {
|
));
|
||||||
resolve_coalesced(&inf, key, &query, || async {
|
|
||||||
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
|
||||||
Ok(mock_response("coalesce.test"))
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut paths = Vec::new();
|
|
||||||
for h in handles {
|
for h in handles {
|
||||||
let (_, path, _) = h.await.unwrap();
|
h.await.unwrap().unwrap();
|
||||||
paths.push(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
|
// Only 1 resolution should have reached the upstream server
|
||||||
assert_eq!(actual, 1, "expected 1 resolution, got {}", actual);
|
let actual = query_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
|
assert_eq!(actual, 1, "expected 1 upstream query, got {}", actual);
|
||||||
|
|
||||||
let recursive = paths.iter().filter(|p| **p == QueryPath::Recursive).count();
|
// Inflight map must be empty after all queries complete
|
||||||
let coalesced = paths.iter().filter(|p| **p == QueryPath::Coalesced).count();
|
assert!(ctx.inflight.lock().unwrap().is_empty());
|
||||||
assert_eq!(recursive, 1, "expected 1 RECURSIVE, got {}", recursive);
|
|
||||||
assert_eq!(coalesced, 4, "expected 4 COALESCED, got {}", coalesced);
|
|
||||||
|
|
||||||
assert!(inflight.lock().unwrap().is_empty());
|
crate::recursive::reset_udp_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn different_qtypes_not_coalesced() {
|
async fn different_qtypes_not_coalesced() {
|
||||||
let inflight = Arc::new(Mutex::new(HashMap::new()));
|
crate::recursive::UDP_DISABLED.store(true, std::sync::atomic::Ordering::Release);
|
||||||
let resolve_count = Arc::new(std::sync::atomic::AtomicU32::new(0));
|
|
||||||
|
|
||||||
let inf1 = inflight.clone();
|
let (server_addr, query_count) = spawn_slow_dns_server(Duration::from_millis(100)).await;
|
||||||
let inf2 = inflight.clone();
|
let ctx = test_recursive_ctx(server_addr).await;
|
||||||
let count1 = resolve_count.clone();
|
let src: SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||||
let count2 = resolve_count.clone();
|
|
||||||
|
|
||||||
let query_a = DnsPacket::query(200, "same.domain", QueryType::A);
|
// Fire A and AAAA concurrently — should NOT coalesce
|
||||||
let query_aaaa = DnsPacket::query(201, "same.domain", QueryType::AAAA);
|
let ctx_ref = ctx.clone();
|
||||||
|
let ctx_ref2 = ctx.clone();
|
||||||
|
let buf_a = build_wire_query(200, "different-qt.example.com", QueryType::A);
|
||||||
|
let buf_aaaa = build_wire_query(201, "different-qt.example.com", QueryType::AAAA);
|
||||||
|
|
||||||
let h1 = tokio::spawn(async move {
|
let h1 = tokio::spawn(async move { handle_query(buf_a, src, &ctx_ref).await });
|
||||||
resolve_coalesced(
|
let h2 = tokio::spawn(async move { handle_query(buf_aaaa, src, &ctx_ref2).await });
|
||||||
&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();
|
h1.await.unwrap().unwrap();
|
||||||
let (_, path2, _) = h2.await.unwrap();
|
h2.await.unwrap().unwrap();
|
||||||
|
|
||||||
let actual = resolve_count.load(std::sync::atomic::Ordering::Relaxed);
|
let actual = query_count.load(std::sync::atomic::Ordering::Relaxed);
|
||||||
assert_eq!(actual, 2, "A and AAAA should each resolve, got {}", actual);
|
assert!(
|
||||||
assert_eq!(path1, QueryPath::Recursive);
|
actual >= 2,
|
||||||
assert_eq!(path2, QueryPath::Recursive);
|
"A and AAAA should resolve independently, got {}",
|
||||||
|
actual
|
||||||
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!(ctx.inflight.lock().unwrap().is_empty());
|
||||||
assert_eq!(resp.questions[0].qtype, QueryType::A);
|
|
||||||
assert_eq!(resp.header.id, 500);
|
crate::recursive::reset_udp_state();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn leader_error_preserves_message() {
|
async fn inflight_map_cleaned_after_upstream_error() {
|
||||||
let inflight: Mutex<InflightMap> = Mutex::new(HashMap::new());
|
// Server that rejects everything — no server running at all
|
||||||
let query = DnsPacket::query(700, "err-msg.test", QueryType::A);
|
let bogus_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||||
|
let ctx = test_recursive_ctx(bogus_addr).await;
|
||||||
|
let src: SocketAddr = "127.0.0.1:9999".parse().unwrap();
|
||||||
|
|
||||||
let (_, path, err) = resolve_coalesced(
|
let buf = build_wire_query(300, "will-fail.example.com", QueryType::A);
|
||||||
&inflight,
|
let _ = handle_query(buf, src, &ctx).await;
|
||||||
("err-msg.test".to_string(), QueryType::A),
|
|
||||||
&query,
|
|
||||||
|| async { Err::<DnsPacket, _>("connection refused by upstream".into()) },
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert_eq!(path, QueryPath::UpstreamError);
|
// Map must be clean even after error
|
||||||
assert_eq!(
|
assert!(ctx.inflight.lock().unwrap().is_empty());
|
||||||
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::QueryType;
|
use crate::question::{DnsQuestion, QueryType};
|
||||||
use crate::record::DnsRecord;
|
use crate::record::DnsRecord;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -160,7 +160,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn make_query() -> DnsPacket {
|
fn make_query() -> DnsPacket {
|
||||||
DnsPacket::query(0xABCD, "example.com", QueryType::A)
|
let mut q = DnsPacket::new();
|
||||||
|
q.header.id = 0xABCD;
|
||||||
|
q.header.recursion_desired = true;
|
||||||
|
q.questions
|
||||||
|
.push(DnsQuestion::new("example.com".to_string(), QueryType::A));
|
||||||
|
q
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_response(query: &DnsPacket) -> DnsPacket {
|
fn make_response(query: &DnsPacket) -> DnsPacket {
|
||||||
|
|||||||
157
src/main.rs
157
src/main.rs
@@ -17,12 +17,10 @@ use numa::query_log::QueryLog;
|
|||||||
use numa::service_store::ServiceStore;
|
use numa::service_store::ServiceStore;
|
||||||
use numa::stats::ServerStats;
|
use numa::stats::ServerStats;
|
||||||
use numa::system_dns::{
|
use numa::system_dns::{
|
||||||
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
discover_system_dns, install_service, install_system_dns, restart_service, service_status,
|
||||||
|
uninstall_service, uninstall_system_dns,
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUAD9_IP: &str = "9.9.9.9";
|
|
||||||
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> numa::Result<()> {
|
async fn main() -> numa::Result<()> {
|
||||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||||
@@ -33,12 +31,12 @@ async fn main() -> numa::Result<()> {
|
|||||||
let arg1 = std::env::args().nth(1).unwrap_or_default();
|
let arg1 = std::env::args().nth(1).unwrap_or_default();
|
||||||
match arg1.as_str() {
|
match arg1.as_str() {
|
||||||
"install" => {
|
"install" => {
|
||||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — installing\n");
|
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — configuring system DNS\n");
|
||||||
return install_service().map_err(|e| e.into());
|
return install_system_dns().map_err(|e| e.into());
|
||||||
}
|
}
|
||||||
"uninstall" => {
|
"uninstall" => {
|
||||||
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — uninstalling\n");
|
eprintln!("\x1b[1;38;2;192;98;58mNuma\x1b[0m — restoring system DNS\n");
|
||||||
return uninstall_service().map_err(|e| e.into());
|
return uninstall_system_dns().map_err(|e| e.into());
|
||||||
}
|
}
|
||||||
"service" => {
|
"service" => {
|
||||||
let sub = std::env::args().nth(2).unwrap_or_default();
|
let sub = std::env::args().nth(2).unwrap_or_default();
|
||||||
@@ -109,81 +107,32 @@ async fn main() -> numa::Result<()> {
|
|||||||
// Discover system DNS in a single pass (upstream + forwarding rules)
|
// Discover system DNS in a single pass (upstream + forwarding rules)
|
||||||
let system_dns = discover_system_dns();
|
let system_dns = discover_system_dns();
|
||||||
|
|
||||||
let root_hints = numa::recursive::parse_root_hints(&config.upstream.root_hints);
|
let upstream_addr = if config.upstream.address.is_empty() {
|
||||||
|
system_dns
|
||||||
let (resolved_mode, upstream_auto, upstream, upstream_label) = match config.upstream.mode {
|
.default_upstream
|
||||||
numa::config::UpstreamMode::Auto => {
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
info!("auto mode: probing recursive resolution...");
|
.unwrap_or_else(|| {
|
||||||
if numa::recursive::probe_recursive(&root_hints).await {
|
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||||
info!("recursive probe succeeded — self-sovereign mode");
|
"https://dns.quad9.net/dns-query".to_string()
|
||||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
})
|
||||||
(
|
} else {
|
||||||
numa::config::UpstreamMode::Recursive,
|
config.upstream.address.clone()
|
||||||
false,
|
|
||||||
dummy,
|
|
||||||
"recursive (root hints)".to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.use_rustls_tls()
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default();
|
|
||||||
let url = DOH_FALLBACK.to_string();
|
|
||||||
let label = url.clone();
|
|
||||||
(
|
|
||||||
numa::config::UpstreamMode::Forward,
|
|
||||||
false,
|
|
||||||
Upstream::Doh { url, client },
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
numa::config::UpstreamMode::Recursive => {
|
|
||||||
let dummy = Upstream::Udp("0.0.0.0:0".parse().unwrap());
|
|
||||||
(
|
|
||||||
numa::config::UpstreamMode::Recursive,
|
|
||||||
false,
|
|
||||||
dummy,
|
|
||||||
"recursive (root hints)".to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
numa::config::UpstreamMode::Forward => {
|
|
||||||
let upstream_addr = if config.upstream.address.is_empty() {
|
|
||||||
system_dns
|
|
||||||
.default_upstream
|
|
||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
|
||||||
DOH_FALLBACK.to_string()
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
config.upstream.address.clone()
|
|
||||||
};
|
|
||||||
|
|
||||||
let upstream: Upstream = if upstream_addr.starts_with("https://") {
|
|
||||||
let client = reqwest::Client::builder()
|
|
||||||
.use_rustls_tls()
|
|
||||||
.build()
|
|
||||||
.unwrap_or_default();
|
|
||||||
Upstream::Doh {
|
|
||||||
url: upstream_addr,
|
|
||||||
client,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let addr: SocketAddr =
|
|
||||||
format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
|
||||||
Upstream::Udp(addr)
|
|
||||||
};
|
|
||||||
let label = upstream.to_string();
|
|
||||||
(
|
|
||||||
numa::config::UpstreamMode::Forward,
|
|
||||||
config.upstream.address.is_empty(),
|
|
||||||
upstream,
|
|
||||||
label,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let upstream: Upstream = if upstream_addr.starts_with("https://") {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.use_rustls_tls()
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default();
|
||||||
|
Upstream::Doh {
|
||||||
|
url: upstream_addr,
|
||||||
|
client,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
|
||||||
|
Upstream::Udp(addr)
|
||||||
|
};
|
||||||
|
let upstream_label = upstream.to_string();
|
||||||
let api_port = config.server.api_port;
|
let api_port = config.server.api_port;
|
||||||
|
|
||||||
let mut blocklist = BlocklistStore::new();
|
let mut blocklist = BlocklistStore::new();
|
||||||
@@ -234,7 +183,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
lan_peers: Mutex::new(numa::lan::PeerStore::new(config.lan.peer_timeout_secs)),
|
||||||
forwarding_rules,
|
forwarding_rules,
|
||||||
upstream: Mutex::new(upstream),
|
upstream: Mutex::new(upstream),
|
||||||
upstream_auto,
|
upstream_auto: config.upstream.address.is_empty(),
|
||||||
upstream_port: config.upstream.port,
|
upstream_port: config.upstream.port,
|
||||||
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
|
lan_ip: Mutex::new(numa::lan::detect_lan_ip().unwrap_or(std::net::Ipv4Addr::LOCALHOST)),
|
||||||
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
timeout: Duration::from_millis(config.upstream.timeout_ms),
|
||||||
@@ -250,8 +199,8 @@ async fn main() -> numa::Result<()> {
|
|||||||
config_dir: numa::config_dir(),
|
config_dir: numa::config_dir(),
|
||||||
data_dir: numa::data_dir(),
|
data_dir: numa::data_dir(),
|
||||||
tls_config: initial_tls,
|
tls_config: initial_tls,
|
||||||
upstream_mode: resolved_mode,
|
upstream_mode: config.upstream.mode,
|
||||||
root_hints,
|
root_hints: numa::recursive::parse_root_hints(&config.upstream.root_hints),
|
||||||
srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)),
|
srtt: std::sync::RwLock::new(numa::srtt::SrttCache::new(config.upstream.srtt)),
|
||||||
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
|
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
|
||||||
dnssec_enabled: config.dnssec.enabled,
|
dnssec_enabled: config.dnssec.enabled,
|
||||||
@@ -259,6 +208,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum();
|
||||||
|
|
||||||
// Build banner rows, then size the box to fit the longest value
|
// Build banner rows, then size the box to fit the longest value
|
||||||
let api_url = format!("http://localhost:{}", api_port);
|
let api_url = format!("http://localhost:{}", api_port);
|
||||||
let proxy_label = if config.proxy.enabled {
|
let proxy_label = if config.proxy.enabled {
|
||||||
@@ -358,17 +308,6 @@ async fn main() -> numa::Result<()> {
|
|||||||
);
|
);
|
||||||
if let Some(ref label) = proxy_label {
|
if let Some(ref label) = proxy_label {
|
||||||
row("Proxy", g, label);
|
row("Proxy", g, label);
|
||||||
if config.proxy.bind_addr == "127.0.0.1" {
|
|
||||||
let y = "\x1b[38;2;204;176;59m"; // yellow
|
|
||||||
row(
|
|
||||||
"",
|
|
||||||
y,
|
|
||||||
&format!(
|
|
||||||
"⚠ proxy on 127.0.0.1 — .{} not LAN reachable",
|
|
||||||
config.proxy.tld
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if config.lan.enabled {
|
if config.lan.enabled {
|
||||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||||
@@ -436,11 +375,16 @@ async fn main() -> numa::Result<()> {
|
|||||||
axum::serve(listener, app).await.unwrap();
|
axum::serve(listener, app).await.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let proxy_bind: std::net::Ipv4Addr = config
|
// Proxy binds 0.0.0.0 when LAN is enabled (cross-machine access), otherwise config value
|
||||||
.proxy
|
let proxy_bind: std::net::Ipv4Addr = if config.lan.enabled {
|
||||||
.bind_addr
|
std::net::Ipv4Addr::UNSPECIFIED
|
||||||
.parse()
|
} else {
|
||||||
.unwrap_or(std::net::Ipv4Addr::LOCALHOST);
|
config
|
||||||
|
.proxy
|
||||||
|
.bind_addr
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(std::net::Ipv4Addr::LOCALHOST)
|
||||||
|
};
|
||||||
|
|
||||||
// Spawn HTTP reverse proxy for .numa domains
|
// Spawn HTTP reverse proxy for .numa domains
|
||||||
if config.proxy.enabled {
|
if config.proxy.enabled {
|
||||||
@@ -481,14 +425,7 @@ 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) = match ctx.socket.recv_from(&mut buffer.buf).await {
|
let (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?;
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {
|
|
||||||
// Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Err(e) => return Err(e.into()),
|
|
||||||
};
|
|
||||||
|
|
||||||
let ctx = Arc::clone(&ctx);
|
let ctx = Arc::clone(&ctx);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -531,7 +468,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
|||||||
let new_addr = dns_info
|
let new_addr = dns_info
|
||||||
.default_upstream
|
.default_upstream
|
||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
.unwrap_or_else(|| QUAD9_IP.to_string());
|
.unwrap_or_else(|| "9.9.9.9".to_string());
|
||||||
if let Ok(new_sock) =
|
if let Ok(new_sock) =
|
||||||
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,22 +117,6 @@ impl OverrideStore {
|
|||||||
self.entries.clear();
|
self.entries.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn heap_bytes(&self) -> usize {
|
|
||||||
let per_slot = std::mem::size_of::<u64>()
|
|
||||||
+ std::mem::size_of::<String>()
|
|
||||||
+ std::mem::size_of::<OverrideEntry>()
|
|
||||||
+ 1;
|
|
||||||
let table = self.entries.capacity() * per_slot;
|
|
||||||
let heap: usize = self
|
|
||||||
.entries
|
|
||||||
.iter()
|
|
||||||
.map(|(k, v)| {
|
|
||||||
k.capacity() + v.domain.capacity() + v.target.capacity() + v.record.heap_bytes()
|
|
||||||
})
|
|
||||||
.sum();
|
|
||||||
table + heap
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn active_count(&self) -> usize {
|
pub fn active_count(&self) -> usize {
|
||||||
self.entries.values().filter(|e| !e.is_expired()).count()
|
self.entries.values().filter(|e| !e.is_expired()).count()
|
||||||
}
|
}
|
||||||
@@ -170,16 +154,3 @@ fn parse_target(domain: &str, target: &str, ttl: u32) -> Result<(QueryType, DnsR
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heap_bytes_grows_with_entries() {
|
|
||||||
let mut store = OverrideStore::new();
|
|
||||||
let empty = store.heap_bytes();
|
|
||||||
store.insert("example.com", "1.2.3.4", 300, None).unwrap();
|
|
||||||
assert!(store.heap_bytes() > empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -57,34 +57,6 @@ impl DnsPacket {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn query(id: u16, domain: &str, qtype: crate::question::QueryType) -> DnsPacket {
|
|
||||||
let mut pkt = DnsPacket::new();
|
|
||||||
pkt.header.id = id;
|
|
||||||
pkt.header.recursion_desired = true;
|
|
||||||
pkt.questions
|
|
||||||
.push(crate::question::DnsQuestion::new(domain.to_string(), qtype));
|
|
||||||
pkt
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heap_bytes(&self) -> usize {
|
|
||||||
fn records_heap(records: &[DnsRecord]) -> usize {
|
|
||||||
records
|
|
||||||
.iter()
|
|
||||||
.map(|r| std::mem::size_of::<DnsRecord>() + r.heap_bytes())
|
|
||||||
.sum::<usize>()
|
|
||||||
}
|
|
||||||
let questions: usize = self
|
|
||||||
.questions
|
|
||||||
.iter()
|
|
||||||
.map(|q| std::mem::size_of::<DnsQuestion>() + q.name.capacity())
|
|
||||||
.sum();
|
|
||||||
questions
|
|
||||||
+ records_heap(&self.answers)
|
|
||||||
+ records_heap(&self.authorities)
|
|
||||||
+ records_heap(&self.resources)
|
|
||||||
+ self.edns.as_ref().map_or(0, |e| e.options.capacity())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket {
|
pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket {
|
||||||
let mut resp = DnsPacket::new();
|
let mut resp = DnsPacket::new();
|
||||||
resp.header.id = query.header.id;
|
resp.header.id = query.header.id;
|
||||||
@@ -610,16 +582,4 @@ mod tests {
|
|||||||
panic!("expected DNSKEY");
|
panic!("expected DNSKEY");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heap_bytes_accounts_for_records() {
|
|
||||||
let mut pkt = DnsPacket::new();
|
|
||||||
let empty = pkt.heap_bytes();
|
|
||||||
pkt.answers.push(DnsRecord::A {
|
|
||||||
domain: "example.com".into(),
|
|
||||||
addr: "1.2.3.4".parse().unwrap(),
|
|
||||||
ttl: 300,
|
|
||||||
});
|
|
||||||
assert!(pkt.heap_bytes() > empty);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,21 +38,6 @@ impl QueryLog {
|
|||||||
self.entries.push_back(entry);
|
self.entries.push_back(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
|
||||||
self.entries.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn is_empty(&self) -> bool {
|
|
||||||
self.entries.is_empty()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn heap_bytes(&self) -> usize {
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.map(|e| std::mem::size_of::<QueryLogEntry>() + e.domain.capacity())
|
|
||||||
.sum()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> {
|
pub fn query(&self, filter: &QueryLogFilter) -> Vec<&QueryLogEntry> {
|
||||||
self.entries
|
self.entries
|
||||||
.iter()
|
.iter()
|
||||||
@@ -92,25 +77,3 @@ pub struct QueryLogFilter {
|
|||||||
pub since: Option<SystemTime>,
|
pub since: Option<SystemTime>,
|
||||||
pub limit: Option<usize>,
|
pub limit: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heap_bytes_grows_with_entries() {
|
|
||||||
let mut log = QueryLog::new(100);
|
|
||||||
let empty = log.heap_bytes();
|
|
||||||
log.push(QueryLogEntry {
|
|
||||||
timestamp: SystemTime::now(),
|
|
||||||
src_addr: "127.0.0.1:1234".parse().unwrap(),
|
|
||||||
domain: "example.com".into(),
|
|
||||||
query_type: QueryType::A,
|
|
||||||
path: QueryPath::Forwarded,
|
|
||||||
rescode: ResultCode::NOERROR,
|
|
||||||
latency_us: 500,
|
|
||||||
dnssec: DnssecStatus::Indeterminate,
|
|
||||||
});
|
|
||||||
assert!(log.heap_bytes() > empty);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -136,46 +136,6 @@ impl DnsRecord {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn heap_bytes(&self) -> usize {
|
|
||||||
match self {
|
|
||||||
DnsRecord::A { domain, .. } => domain.capacity(),
|
|
||||||
DnsRecord::NS { domain, host, .. } | DnsRecord::CNAME { domain, host, .. } => {
|
|
||||||
domain.capacity() + host.capacity()
|
|
||||||
}
|
|
||||||
DnsRecord::MX { domain, host, .. } => domain.capacity() + host.capacity(),
|
|
||||||
DnsRecord::AAAA { domain, .. } => domain.capacity(),
|
|
||||||
DnsRecord::DNSKEY {
|
|
||||||
domain, public_key, ..
|
|
||||||
} => domain.capacity() + public_key.capacity(),
|
|
||||||
DnsRecord::DS { domain, digest, .. } => domain.capacity() + digest.capacity(),
|
|
||||||
DnsRecord::RRSIG {
|
|
||||||
domain,
|
|
||||||
signer_name,
|
|
||||||
signature,
|
|
||||||
..
|
|
||||||
} => domain.capacity() + signer_name.capacity() + signature.capacity(),
|
|
||||||
DnsRecord::NSEC {
|
|
||||||
domain,
|
|
||||||
next_domain,
|
|
||||||
type_bitmap,
|
|
||||||
..
|
|
||||||
} => domain.capacity() + next_domain.capacity() + type_bitmap.capacity(),
|
|
||||||
DnsRecord::NSEC3 {
|
|
||||||
domain,
|
|
||||||
salt,
|
|
||||||
next_hashed_owner,
|
|
||||||
type_bitmap,
|
|
||||||
..
|
|
||||||
} => {
|
|
||||||
domain.capacity()
|
|
||||||
+ salt.capacity()
|
|
||||||
+ next_hashed_owner.capacity()
|
|
||||||
+ type_bitmap.capacity()
|
|
||||||
}
|
|
||||||
DnsRecord::UNKNOWN { domain, data, .. } => domain.capacity() + data.capacity(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_ttl(&mut self, new_ttl: u32) {
|
pub fn set_ttl(&mut self, new_ttl: u32) {
|
||||||
match self {
|
match self {
|
||||||
DnsRecord::A { ttl, .. }
|
DnsRecord::A { ttl, .. }
|
||||||
@@ -690,14 +650,4 @@ mod tests {
|
|||||||
let parsed = round_trip(&rec);
|
let parsed = round_trip(&rec);
|
||||||
assert_eq!(rec, parsed);
|
assert_eq!(rec, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn heap_bytes_reflects_string_capacity() {
|
|
||||||
let rec = DnsRecord::CNAME {
|
|
||||||
domain: "a]".repeat(100),
|
|
||||||
host: "b".repeat(200),
|
|
||||||
ttl: 60,
|
|
||||||
};
|
|
||||||
assert!(rec.heap_bytes() >= 300);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::cache::DnsCache;
|
|||||||
use crate::forward::forward_udp;
|
use crate::forward::forward_udp;
|
||||||
use crate::header::ResultCode;
|
use crate::header::ResultCode;
|
||||||
use crate::packet::DnsPacket;
|
use crate::packet::DnsPacket;
|
||||||
use crate::question::QueryType;
|
use crate::question::{DnsQuestion, QueryType};
|
||||||
use crate::record::DnsRecord;
|
use crate::record::DnsRecord;
|
||||||
use crate::srtt::SrttCache;
|
use crate::srtt::SrttCache;
|
||||||
|
|
||||||
@@ -32,14 +32,6 @@ fn dns_addr(ip: impl Into<IpAddr>) -> SocketAddr {
|
|||||||
SocketAddr::new(ip.into(), 53)
|
SocketAddr::new(ip.into(), 53)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record_to_addr(rec: &DnsRecord) -> Option<SocketAddr> {
|
|
||||||
match rec {
|
|
||||||
DnsRecord::A { addr, .. } => Some(dns_addr(*addr)),
|
|
||||||
DnsRecord::AAAA { addr, .. } => Some(dns_addr(*addr)),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn reset_udp_state() {
|
pub fn reset_udp_state() {
|
||||||
UDP_DISABLED.store(false, Ordering::Release);
|
UDP_DISABLED.store(false, Ordering::Release);
|
||||||
UDP_FAILURES.store(0, Ordering::Release);
|
UDP_FAILURES.store(0, Ordering::Release);
|
||||||
@@ -54,8 +46,11 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
|
|||||||
Some(h) => *h,
|
Some(h) => *h,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
|
let mut probe = DnsPacket::new();
|
||||||
probe.header.recursion_desired = false;
|
probe.header.id = next_id();
|
||||||
|
probe
|
||||||
|
.questions
|
||||||
|
.push(DnsQuestion::new(".".to_string(), QueryType::NS));
|
||||||
if forward_udp(&probe, hint, Duration::from_millis(1500))
|
if forward_udp(&probe, hint, Duration::from_millis(1500))
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.is_ok()
|
||||||
@@ -65,21 +60,6 @@ pub async fn probe_udp(root_hints: &[SocketAddr]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe whether recursive resolution works by querying root servers.
|
|
||||||
/// Tries up to 3 hints before declaring failure.
|
|
||||||
pub async fn probe_recursive(root_hints: &[SocketAddr]) -> bool {
|
|
||||||
let mut probe = DnsPacket::query(next_id(), ".", QueryType::NS);
|
|
||||||
probe.header.recursion_desired = false;
|
|
||||||
for hint in root_hints.iter().take(3) {
|
|
||||||
if let Ok(resp) = forward_udp(&probe, *hint, Duration::from_secs(3)).await {
|
|
||||||
if !resp.answers.is_empty() || !resp.authorities.is_empty() {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn prime_tld_cache(
|
pub async fn prime_tld_cache(
|
||||||
cache: &RwLock<DnsCache>,
|
cache: &RwLock<DnsCache>,
|
||||||
root_hints: &[SocketAddr],
|
root_hints: &[SocketAddr],
|
||||||
@@ -316,8 +296,17 @@ pub(crate) fn resolve_iterative<'a>(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
new_ns_addrs
|
for rec in &ns_resp.answers {
|
||||||
.extend(ns_resp.answers.iter().filter_map(record_to_addr));
|
match rec {
|
||||||
|
DnsRecord::A { addr, .. } => {
|
||||||
|
new_ns_addrs.push(dns_addr(*addr));
|
||||||
|
}
|
||||||
|
DnsRecord::AAAA { addr, .. } => {
|
||||||
|
new_ns_addrs.push(dns_addr(*addr));
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !new_ns_addrs.is_empty() {
|
if !new_ns_addrs.is_empty() {
|
||||||
break;
|
break;
|
||||||
@@ -371,7 +360,13 @@ fn find_closest_ns(
|
|||||||
if let DnsRecord::NS { host, .. } = ns_rec {
|
if let DnsRecord::NS { host, .. } = ns_rec {
|
||||||
for qt in [QueryType::A, QueryType::AAAA] {
|
for qt in [QueryType::A, QueryType::AAAA] {
|
||||||
if let Some(resp) = guard.lookup(host, qt) {
|
if let Some(resp) = guard.lookup(host, qt) {
|
||||||
addrs.extend(resp.answers.iter().filter_map(record_to_addr));
|
for rec in &resp.answers {
|
||||||
|
match rec {
|
||||||
|
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||||
|
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -457,7 +452,13 @@ fn addrs_from_cache(cache: &RwLock<DnsCache>, name: &str) -> Vec<SocketAddr> {
|
|||||||
let mut addrs = Vec::new();
|
let mut addrs = Vec::new();
|
||||||
for qt in [QueryType::A, QueryType::AAAA] {
|
for qt in [QueryType::A, QueryType::AAAA] {
|
||||||
if let Some(pkt) = guard.lookup(name, qt) {
|
if let Some(pkt) = guard.lookup(name, qt) {
|
||||||
addrs.extend(pkt.answers.iter().filter_map(record_to_addr));
|
for rec in &pkt.answers {
|
||||||
|
match rec {
|
||||||
|
DnsRecord::A { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||||
|
DnsRecord::AAAA { addr, .. } => addrs.push(dns_addr(*addr)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
addrs
|
addrs
|
||||||
@@ -467,13 +468,15 @@ fn glue_addrs_for(response: &DnsPacket, ns_name: &str) -> Vec<SocketAddr> {
|
|||||||
response
|
response
|
||||||
.resources
|
.resources
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| match r {
|
.filter_map(|r| match r {
|
||||||
DnsRecord::A { domain, .. } | DnsRecord::AAAA { domain, .. } => {
|
DnsRecord::A { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||||
domain.eq_ignore_ascii_case(ns_name)
|
Some(dns_addr(*addr))
|
||||||
}
|
}
|
||||||
_ => false,
|
DnsRecord::AAAA { domain, addr, .. } if domain.eq_ignore_ascii_case(ns_name) => {
|
||||||
|
Some(dns_addr(*addr))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
})
|
})
|
||||||
.filter_map(record_to_addr)
|
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,8 +596,12 @@ async fn send_query(
|
|||||||
server: SocketAddr,
|
server: SocketAddr,
|
||||||
srtt: &RwLock<SrttCache>,
|
srtt: &RwLock<SrttCache>,
|
||||||
) -> crate::Result<DnsPacket> {
|
) -> crate::Result<DnsPacket> {
|
||||||
let mut query = DnsPacket::query(next_id(), qname, qtype);
|
let mut query = DnsPacket::new();
|
||||||
|
query.header.id = next_id();
|
||||||
query.header.recursion_desired = false;
|
query.header.recursion_desired = false;
|
||||||
|
query
|
||||||
|
.questions
|
||||||
|
.push(DnsQuestion::new(qname.to_string(), qtype));
|
||||||
query.edns = Some(crate::packet::EdnsOpt {
|
query.edns = Some(crate::packet::EdnsOpt {
|
||||||
do_bit: true,
|
do_bit: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -1049,7 +1056,11 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let query = DnsPacket::query(0xBEEF, "test.com", QueryType::A);
|
let mut query = DnsPacket::new();
|
||||||
|
query.header.id = 0xBEEF;
|
||||||
|
query
|
||||||
|
.questions
|
||||||
|
.push(DnsQuestion::new("test.com".to_string(), QueryType::A));
|
||||||
|
|
||||||
let resp = crate::forward::forward_tcp(&query, server_addr, Duration::from_secs(2))
|
let resp = crate::forward::forward_tcp(&query, server_addr, Duration::from_secs(2))
|
||||||
.await
|
.await
|
||||||
@@ -1109,7 +1120,11 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
});
|
});
|
||||||
|
|
||||||
let query = DnsPacket::query(0xCAFE, "strict.test", QueryType::A);
|
let mut query = DnsPacket::new();
|
||||||
|
query.header.id = 0xCAFE;
|
||||||
|
query
|
||||||
|
.questions
|
||||||
|
.push(DnsQuestion::new("strict.test".to_string(), QueryType::A));
|
||||||
|
|
||||||
let resp = crate::forward::forward_tcp(&query, addr, Duration::from_secs(2))
|
let resp = crate::forward::forward_tcp(&query, addr, Duration::from_secs(2))
|
||||||
.await
|
.await
|
||||||
|
|||||||
117
src/srtt.rs
117
src/srtt.rs
@@ -47,19 +47,16 @@ impl SrttCache {
|
|||||||
|
|
||||||
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
||||||
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
||||||
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
|
let age_secs = entry.updated_at.elapsed().as_secs();
|
||||||
}
|
|
||||||
|
|
||||||
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
|
|
||||||
if age_secs > DECAY_AFTER_SECS {
|
if age_secs > DECAY_AFTER_SECS {
|
||||||
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
||||||
let mut srtt = srtt_ms;
|
let mut srtt = entry.srtt_ms;
|
||||||
for _ in 0..periods {
|
for _ in 0..periods {
|
||||||
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
||||||
}
|
}
|
||||||
srtt
|
srtt
|
||||||
} else {
|
} else {
|
||||||
srtt_ms
|
entry.srtt_ms
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,14 +100,6 @@ impl SrttCache {
|
|||||||
addrs.sort_by_key(|a| self.get(a.ip()));
|
addrs.sort_by_key(|a| self.get(a.ip()));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn heap_bytes(&self) -> usize {
|
|
||||||
let per_slot = std::mem::size_of::<u64>()
|
|
||||||
+ std::mem::size_of::<IpAddr>()
|
|
||||||
+ std::mem::size_of::<SrttEntry>()
|
|
||||||
+ 1;
|
|
||||||
self.entries.capacity() * per_slot
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.entries.len()
|
self.entries.len()
|
||||||
}
|
}
|
||||||
@@ -119,6 +108,13 @@ impl SrttCache {
|
|||||||
self.entries.is_empty()
|
self.entries.is_empty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn set_updated_at(&mut self, ip: IpAddr, at: Instant) {
|
||||||
|
if let Some(entry) = self.entries.get_mut(&ip) {
|
||||||
|
entry.updated_at = at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn maybe_evict(&mut self) {
|
fn maybe_evict(&mut self) {
|
||||||
if self.entries.len() < MAX_ENTRIES {
|
if self.entries.len() < MAX_ENTRIES {
|
||||||
return;
|
return;
|
||||||
@@ -214,41 +210,63 @@ mod tests {
|
|||||||
assert_eq!(addrs, original);
|
assert_eq!(addrs, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn age(secs: u64) -> Instant {
|
||||||
|
Instant::now() - std::time::Duration::from_secs(secs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cache with ip(1) saturated at FAILURE_PENALTY_MS
|
||||||
|
fn saturated_penalty_cache() -> SrttCache {
|
||||||
|
let mut cache = SrttCache::new(true);
|
||||||
|
for _ in 0..30 {
|
||||||
|
cache.record_rtt(ip(1), FAILURE_PENALTY_MS, false);
|
||||||
|
}
|
||||||
|
cache
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_decay_within_threshold() {
|
fn no_decay_within_threshold() {
|
||||||
// At exactly DECAY_AFTER_SECS, no decay applied
|
let mut cache = SrttCache::new(true);
|
||||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS);
|
cache.record_rtt(ip(1), 5000, false);
|
||||||
assert_eq!(result, FAILURE_PENALTY_MS);
|
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS));
|
||||||
|
assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_decay_period() {
|
fn one_decay_period() {
|
||||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1);
|
let mut cache = saturated_penalty_cache();
|
||||||
let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2;
|
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||||
assert_eq!(result, expected);
|
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1));
|
||||||
|
let expected = (raw + INITIAL_SRTT_MS) / 2;
|
||||||
|
assert_eq!(cache.get(ip(1)), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn multiple_decay_periods() {
|
fn multiple_decay_periods() {
|
||||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1);
|
let mut cache = saturated_penalty_cache();
|
||||||
let mut expected = FAILURE_PENALTY_MS;
|
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||||
|
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1));
|
||||||
|
let mut expected = raw;
|
||||||
for _ in 0..4 {
|
for _ in 0..4 {
|
||||||
expected = (expected + INITIAL_SRTT_MS) / 2;
|
expected = (expected + INITIAL_SRTT_MS) / 2;
|
||||||
}
|
}
|
||||||
assert_eq!(result, expected);
|
assert_eq!(cache.get(ip(1)), expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_caps_at_8_periods() {
|
fn decay_caps_at_8_periods() {
|
||||||
// 9 periods and 100 periods should produce the same result (capped at 8)
|
// 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 mut cache_a = saturated_penalty_cache();
|
||||||
let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
let mut cache_b = saturated_penalty_cache();
|
||||||
assert_eq!(a, b);
|
cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1));
|
||||||
|
cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||||
|
assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_converges_toward_initial() {
|
fn decay_converges_toward_initial() {
|
||||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
let mut cache = saturated_penalty_cache();
|
||||||
|
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||||
|
let decayed = cache.get(ip(1));
|
||||||
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
||||||
assert!(
|
assert!(
|
||||||
diff < 25,
|
diff < 25,
|
||||||
@@ -260,38 +278,29 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn record_rtt_applies_decay_before_ewma() {
|
fn record_rtt_applies_decay_before_ewma() {
|
||||||
// Verify decay is applied before EWMA in record_rtt by checking
|
let mut cache = saturated_penalty_cache();
|
||||||
// that a saturated penalty + long age + new sample produces a low SRTT
|
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8));
|
||||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8);
|
cache.record_rtt(ip(1), 50, false);
|
||||||
// EWMA: (decayed * 7 + 50) / 8
|
let srtt = cache.get(ip(1));
|
||||||
let after_ewma = (decayed * 7 + 50) / 8;
|
// Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381
|
||||||
assert!(
|
assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt);
|
||||||
after_ewma < 500,
|
|
||||||
"expected decay before EWMA, got srtt={}",
|
|
||||||
after_ewma
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decay_reranks_stale_failures() {
|
fn decay_reranks_stale_failures() {
|
||||||
// After enough decay, a failed server (5000ms) converges toward
|
let mut cache = saturated_penalty_cache();
|
||||||
// INITIAL (200ms), which is below a stable server at 300ms
|
for _ in 0..30 {
|
||||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
cache.record_rtt(ip(2), 300, false);
|
||||||
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);
|
let mut addrs = vec![sock(1), sock(2)];
|
||||||
|
cache.sort_by_rtt(&mut addrs);
|
||||||
|
assert_eq!(addrs, vec![sock(2), sock(1)]);
|
||||||
|
|
||||||
|
// Age server 1 so it decays toward INITIAL (200ms) — below server 2's 300ms
|
||||||
|
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||||
|
let mut addrs = vec![sock(1), sock(2)];
|
||||||
|
cache.sort_by_rtt(&mut addrs);
|
||||||
|
assert_eq!(addrs, vec![sock(1), sock(2)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
89
src/stats.rs
89
src/stats.rs
@@ -1,92 +1,5 @@
|
|||||||
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,
|
||||||
@@ -100,7 +13,7 @@ pub struct ServerStats {
|
|||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum QueryPath {
|
pub enum QueryPath {
|
||||||
Local,
|
Local,
|
||||||
Cached,
|
Cached,
|
||||||
|
|||||||
@@ -2,10 +2,6 @@ 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 {
|
||||||
@@ -30,7 +26,10 @@ pub fn discover_system_dns() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
{
|
{
|
||||||
discover_linux()
|
SystemDnsInfo {
|
||||||
|
default_upstream: detect_upstream_linux_or_backup(),
|
||||||
|
forwarding_rules: Vec::new(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -103,7 +102,11 @@ 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 && default_upstream.is_none() && !is_loopback_or_stub(&ns) {
|
if !is_supplemental
|
||||||
|
&& default_upstream.is_none()
|
||||||
|
&& ns != "127.0.0.1"
|
||||||
|
&& ns != "0.0.0.0"
|
||||||
|
{
|
||||||
default_upstream = Some(ns);
|
default_upstream = Some(ns);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,7 +156,7 @@ fn discover_macos() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
#[cfg(target_os = "macos")]
|
||||||
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 {
|
||||||
@@ -163,100 +166,38 @@ 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")]
|
||||||
const CLOUD_VPC_RESOLVER: &str = "169.254.169.253";
|
fn detect_upstream_linux_or_backup() -> Option<String> {
|
||||||
|
// Try /etc/resolv.conf first
|
||||||
#[cfg(target_os = "linux")]
|
if let Some(ns) = read_upstream_from_file("/etc/resolv.conf") {
|
||||||
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);
|
||||||
Some(ns)
|
return Some(ns);
|
||||||
} else {
|
|
||||||
// Fallback to backup from a previous `numa install`
|
|
||||||
let backup = {
|
|
||||||
let home = std::env::var("HOME")
|
|
||||||
.map(std::path::PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
|
|
||||||
home.join(".numa").join("original-resolv.conf")
|
|
||||||
};
|
|
||||||
let (ns, _) = parse_resolv_conf(backup.to_str().unwrap_or(""));
|
|
||||||
if let Some(ref ns) = ns {
|
|
||||||
info!("detected original upstream from backup: {}", ns);
|
|
||||||
}
|
|
||||||
ns
|
|
||||||
};
|
|
||||||
|
|
||||||
// On cloud VMs (AWS/GCP), internal domains need to reach the VPC resolver
|
|
||||||
let forwarding_rules = if search_domains.is_empty() {
|
|
||||||
Vec::new()
|
|
||||||
} else {
|
|
||||||
let forwarder = resolvectl_dns_server().unwrap_or_else(|| CLOUD_VPC_RESOLVER.to_string());
|
|
||||||
let rules: Vec<_> = search_domains
|
|
||||||
.iter()
|
|
||||||
.filter_map(|domain| {
|
|
||||||
let rule = make_rule(domain, &forwarder)?;
|
|
||||||
info!("forwarding .{} to {}", domain, forwarder);
|
|
||||||
Some(rule)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if !rules.is_empty() {
|
|
||||||
info!("detected {} search domain forwarding rules", rules.len());
|
|
||||||
}
|
|
||||||
rules
|
|
||||||
};
|
|
||||||
|
|
||||||
SystemDnsInfo {
|
|
||||||
default_upstream,
|
|
||||||
forwarding_rules,
|
|
||||||
}
|
}
|
||||||
|
// If resolv.conf only has loopback, check the backup from `numa install`
|
||||||
|
let backup = {
|
||||||
|
let home = std::env::var("HOME")
|
||||||
|
.map(std::path::PathBuf::from)
|
||||||
|
.unwrap_or_else(|_| std::path::PathBuf::from("/root"));
|
||||||
|
home.join(".numa").join("original-resolv.conf")
|
||||||
|
};
|
||||||
|
if let Some(ns) = read_upstream_from_file(backup.to_str().unwrap_or("")) {
|
||||||
|
info!("detected original upstream from backup: {}", ns);
|
||||||
|
return Some(ns);
|
||||||
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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 parse_resolv_conf(path: &str) -> (Option<String>, Vec<String>) {
|
fn read_upstream_from_file(path: &str) -> Option<String> {
|
||||||
let text = match std::fs::read_to_string(path) {
|
let text = std::fs::read_to_string(path).ok()?;
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,7 +236,10 @@ 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 !is_loopback_or_stub(addr) && addr.parse::<std::net::Ipv4Addr>().is_ok()
|
if !addr.is_empty()
|
||||||
|
&& 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());
|
||||||
@@ -334,7 +278,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.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
if !ip.is_empty() && ip != "127.0.0.1" && ip != "::1" {
|
||||||
upstream = Some(ip.to_string());
|
upstream = Some(ip.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -358,339 +302,6 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
|
||||||
struct WindowsInterfaceDns {
|
|
||||||
dhcp: bool,
|
|
||||||
servers: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
|
||||||
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
|
|
||||||
let mut interfaces = std::collections::HashMap::new();
|
|
||||||
let mut current_adapter: Option<String> = None;
|
|
||||||
let mut current_dhcp = false;
|
|
||||||
let mut current_dns: Vec<String> = Vec::new();
|
|
||||||
let mut in_dns_block = false;
|
|
||||||
let mut disconnected = false;
|
|
||||||
|
|
||||||
for line in text.lines() {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
|
|
||||||
// Adapter section headers start at column 0
|
|
||||||
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
|
|
||||||
if let Some(name) = current_adapter.take() {
|
|
||||||
if !disconnected {
|
|
||||||
interfaces.insert(
|
|
||||||
name,
|
|
||||||
WindowsInterfaceDns {
|
|
||||||
dhcp: current_dhcp,
|
|
||||||
servers: std::mem::take(&mut current_dns),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
current_dns.clear();
|
|
||||||
}
|
|
||||||
in_dns_block = false;
|
|
||||||
current_dhcp = false;
|
|
||||||
disconnected = false;
|
|
||||||
|
|
||||||
// "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German)
|
|
||||||
let lower = trimmed.to_lowercase();
|
|
||||||
if let Some(pos) = lower.find(" adapter ") {
|
|
||||||
let after = &trimmed[pos + " adapter ".len()..];
|
|
||||||
let name = after.trim_end_matches(':').trim();
|
|
||||||
if !name.is_empty() {
|
|
||||||
current_adapter = Some(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if current_adapter.is_some() {
|
|
||||||
if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") {
|
|
||||||
disconnected = true;
|
|
||||||
} else if trimmed.contains("DHCP") && trimmed.contains(". .") {
|
|
||||||
current_dhcp = trimmed
|
|
||||||
.split(':')
|
|
||||||
.next_back()
|
|
||||||
.map(|v| {
|
|
||||||
let v = v.trim().to_lowercase();
|
|
||||||
v == "yes" || v == "ja"
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
in_dns_block = false;
|
|
||||||
} else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
|
||||||
in_dns_block = true;
|
|
||||||
if let Some(ip) = trimmed.split(':').next_back() {
|
|
||||||
let ip = ip.trim();
|
|
||||||
if ip.parse::<std::net::IpAddr>().is_ok() {
|
|
||||||
current_dns.push(ip.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if in_dns_block {
|
|
||||||
if trimmed.parse::<std::net::IpAddr>().is_ok() {
|
|
||||||
current_dns.push(trimmed.to_string());
|
|
||||||
} else {
|
|
||||||
in_dns_block = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = current_adapter {
|
|
||||||
if !disconnected {
|
|
||||||
interfaces.insert(
|
|
||||||
name,
|
|
||||||
WindowsInterfaceDns {
|
|
||||||
dhcp: current_dhcp,
|
|
||||||
servers: current_dns,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interfaces
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn get_windows_interfaces() -> Result<std::collections::HashMap<String, WindowsInterfaceDns>, String>
|
|
||||||
{
|
|
||||||
let output = std::process::Command::new("ipconfig")
|
|
||||||
.arg("/all")
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("failed to run ipconfig /all: {}", e))?;
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
Ok(parse_ipconfig_interfaces(&text))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn windows_backup_path() -> std::path::PathBuf {
|
|
||||||
// Use ProgramData (not APPDATA) since install requires admin elevation
|
|
||||||
// and APPDATA differs between user and admin contexts.
|
|
||||||
std::path::PathBuf::from(
|
|
||||||
std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()),
|
|
||||||
)
|
|
||||||
.join("numa")
|
|
||||||
.join("original-dns.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn disable_dnscache() -> Result<bool, String> {
|
|
||||||
// Check if Dnscache is running (it holds port 53 at kernel level)
|
|
||||||
let output = std::process::Command::new("sc")
|
|
||||||
.args(["query", "Dnscache"])
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("failed to query Dnscache: {}", e))?;
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
if !text.contains("RUNNING") {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(" Disabling DNS Client (Dnscache) to free port 53...");
|
|
||||||
// Dnscache can't be stopped via sc/net stop — must disable via registry
|
|
||||||
let status = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"add",
|
|
||||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
|
||||||
"/v",
|
|
||||||
"Start",
|
|
||||||
"/t",
|
|
||||||
"REG_DWORD",
|
|
||||||
"/d",
|
|
||||||
"4",
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to disable Dnscache: {}", e))?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
return Err("failed to disable Dnscache via registry (run as Administrator?)".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(" Dnscache disabled. A reboot is required to free port 53.");
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn enable_dnscache() {
|
|
||||||
let _ = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"add",
|
|
||||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
|
||||||
"/v",
|
|
||||||
"Start",
|
|
||||||
"/t",
|
|
||||||
"REG_DWORD",
|
|
||||||
"/d",
|
|
||||||
"2",
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn install_windows() -> Result<(), String> {
|
|
||||||
let interfaces = get_windows_interfaces()?;
|
|
||||||
if interfaces.is_empty() {
|
|
||||||
return Err("no active network interfaces found".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = windows_backup_path();
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
|
||||||
}
|
|
||||||
let json = serde_json::to_string_pretty(&interfaces)
|
|
||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
|
||||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
|
||||||
|
|
||||||
for name in interfaces.keys() {
|
|
||||||
let status = std::process::Command::new("netsh")
|
|
||||||
.args([
|
|
||||||
"interface",
|
|
||||||
"ipv4",
|
|
||||||
"set",
|
|
||||||
"dnsservers",
|
|
||||||
name,
|
|
||||||
"static",
|
|
||||||
"127.0.0.1",
|
|
||||||
"primary",
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to set DNS for {}: {}", name, e))?;
|
|
||||||
|
|
||||||
if status.success() {
|
|
||||||
eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name);
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
" warning: failed to set DNS for \"{}\" (run as Administrator?)",
|
|
||||||
name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let needs_reboot = disable_dnscache()?;
|
|
||||||
register_autostart();
|
|
||||||
|
|
||||||
eprintln!("\n Original DNS saved to {}", path.display());
|
|
||||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
|
||||||
if needs_reboot {
|
|
||||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
|
||||||
} else {
|
|
||||||
eprintln!(" Numa will start automatically on next boot.\n");
|
|
||||||
}
|
|
||||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
|
||||||
eprintln!(" [upstream]");
|
|
||||||
eprintln!(" mode = \"recursive\"\n");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register numa to auto-start on boot via registry Run key.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn register_autostart() {
|
|
||||||
let exe = std::env::current_exe()
|
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|_| "numa".into());
|
|
||||||
let _ = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"add",
|
|
||||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
||||||
"/v",
|
|
||||||
"Numa",
|
|
||||||
"/t",
|
|
||||||
"REG_SZ",
|
|
||||||
"/d",
|
|
||||||
&exe,
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
eprintln!(" Registered auto-start on boot.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove numa auto-start registry key.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn remove_autostart() {
|
|
||||||
let _ = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"delete",
|
|
||||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
||||||
"/v",
|
|
||||||
"Numa",
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn uninstall_windows() -> Result<(), String> {
|
|
||||||
remove_autostart();
|
|
||||||
let path = windows_backup_path();
|
|
||||||
let json = std::fs::read_to_string(&path)
|
|
||||||
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
|
|
||||||
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
|
|
||||||
serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?;
|
|
||||||
|
|
||||||
for (name, dns_info) in &original {
|
|
||||||
if dns_info.dhcp || dns_info.servers.is_empty() {
|
|
||||||
let status = std::process::Command::new("netsh")
|
|
||||||
.args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
|
||||||
|
|
||||||
if status.success() {
|
|
||||||
eprintln!(" restored DNS for \"{}\" -> DHCP", name);
|
|
||||||
} else {
|
|
||||||
eprintln!(" warning: failed to restore DNS for \"{}\"", name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let status = std::process::Command::new("netsh")
|
|
||||||
.args([
|
|
||||||
"interface",
|
|
||||||
"ipv4",
|
|
||||||
"set",
|
|
||||||
"dnsservers",
|
|
||||||
name,
|
|
||||||
"static",
|
|
||||||
&dns_info.servers[0],
|
|
||||||
"primary",
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
eprintln!(" warning: failed to restore primary DNS for \"{}\"", name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, server) in dns_info.servers.iter().skip(1).enumerate() {
|
|
||||||
let _ = std::process::Command::new("netsh")
|
|
||||||
.args([
|
|
||||||
"interface",
|
|
||||||
"ipv4",
|
|
||||||
"add",
|
|
||||||
"dnsservers",
|
|
||||||
name,
|
|
||||||
server,
|
|
||||||
&format!("index={}", i + 2),
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
" restored DNS for \"{}\" -> {}",
|
|
||||||
name,
|
|
||||||
dns_info.servers.join(", ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
|
|
||||||
// Re-enable Dnscache
|
|
||||||
enable_dnscache();
|
|
||||||
eprintln!("\n System DNS restored. DNS Client re-enabled.");
|
|
||||||
eprintln!(" Reboot to fully restore the DNS Client service.\n");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the upstream for a domain by checking forwarding rules.
|
/// 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.
|
||||||
@@ -705,6 +316,43 @@ 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")]
|
||||||
@@ -776,7 +424,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 and add "numa" search domain for each service
|
// Set DNS to 127.0.0.1 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"])
|
||||||
@@ -788,11 +436,6 @@ 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());
|
||||||
@@ -837,11 +480,6 @@ 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();
|
||||||
@@ -862,27 +500,21 @@ 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();
|
{
|
||||||
#[cfg(target_os = "linux")]
|
install_service_macos()
|
||||||
let result = install_service_linux();
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(target_os = "linux")]
|
||||||
let result = install_windows();
|
{
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
install_service_linux()
|
||||||
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
}
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
if result.is_ok() {
|
{
|
||||||
if let Err(e) = trust_ca() {
|
Err("service installation not supported on this OS".to_string())
|
||||||
eprintln!(" warning: could not trust CA: {}", e);
|
|
||||||
eprintln!(" HTTPS proxy will work but browsers will show certificate warnings.\n");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Uninstall the Numa system service.
|
/// Uninstall the Numa system service.
|
||||||
pub fn uninstall_service() -> Result<(), String> {
|
pub fn uninstall_service() -> Result<(), String> {
|
||||||
let _ = untrust_ca();
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
uninstall_service_macos()
|
uninstall_service_macos()
|
||||||
@@ -891,11 +523,7 @@ pub fn uninstall_service() -> Result<(), String> {
|
|||||||
{
|
{
|
||||||
uninstall_service_linux()
|
uninstall_service_linux()
|
||||||
}
|
}
|
||||||
#[cfg(windows)]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
{
|
|
||||||
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())
|
||||||
}
|
}
|
||||||
@@ -903,13 +531,9 @@ pub fn uninstall_service() -> Result<(), String> {
|
|||||||
|
|
||||||
/// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
|
/// Restart the service (kill process, launchd/systemd auto-restarts with new binary).
|
||||||
pub fn restart_service() -> Result<(), String> {
|
pub fn restart_service() -> Result<(), String> {
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
||||||
let exe_path =
|
|
||||||
std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?;
|
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||||
let version = {
|
let version = {
|
||||||
match std::process::Command::new(&exe_path)
|
match std::process::Command::new("/usr/local/bin/numa")
|
||||||
.arg("--version")
|
.arg("--version")
|
||||||
.output()
|
.output()
|
||||||
{
|
{
|
||||||
@@ -920,7 +544,6 @@ pub fn restart_service() -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
let exe_path = exe_path.to_string_lossy();
|
|
||||||
let output = std::process::Command::new("launchctl")
|
let output = std::process::Command::new("launchctl")
|
||||||
.args(["list", PLIST_LABEL])
|
.args(["list", PLIST_LABEL])
|
||||||
.output();
|
.output();
|
||||||
@@ -931,11 +554,11 @@ pub fn restart_service() -> Result<(), String> {
|
|||||||
// This will kill us too (we ARE /usr/local/bin/numa), so
|
// This will kill us too (we ARE /usr/local/bin/numa), so
|
||||||
// codesign and print output first.
|
// codesign and print output first.
|
||||||
let _ = std::process::Command::new("codesign")
|
let _ = std::process::Command::new("codesign")
|
||||||
.args(["-f", "-s", "-", &exe_path])
|
.args(["-f", "-s", "-", "/usr/local/bin/numa"])
|
||||||
.output(); // use output() to suppress codesign stderr
|
.output(); // use output() to suppress codesign stderr
|
||||||
eprintln!(" Service restarting → {}\n", version);
|
eprintln!(" Service restarting → {}\n", version);
|
||||||
let _ = std::process::Command::new("pkill")
|
let _ = std::process::Command::new("pkill")
|
||||||
.args(["-f", &exe_path])
|
.args(["-f", "/usr/local/bin/numa"])
|
||||||
.status();
|
.status();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -970,27 +593,23 @@ pub fn service_status() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
||||||
fn replace_exe_path(service: &str) -> Result<String, String> {
|
|
||||||
let exe_path =
|
|
||||||
std::env::current_exe().map_err(|e| format!("failed to get current exe: {}", e))?;
|
|
||||||
Ok(service.replace("{{exe_path}}", &exe_path.to_string_lossy()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn install_service_macos() -> Result<(), String> {
|
fn install_service_macos() -> Result<(), String> {
|
||||||
|
// Check binary exists
|
||||||
|
if !std::path::Path::new("/usr/local/bin/numa").exists() {
|
||||||
|
return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Create log directory
|
// Create log directory
|
||||||
std::fs::create_dir_all("/usr/local/var/log")
|
std::fs::create_dir_all("/usr/local/var/log")
|
||||||
.map_err(|e| format!("failed to create log dir: {}", e))?;
|
.map_err(|e| format!("failed to create log dir: {}", e))?;
|
||||||
|
|
||||||
// Write plist
|
// Write plist
|
||||||
let plist = include_str!("../com.numa.dns.plist");
|
let plist = include_str!("../com.numa.dns.plist");
|
||||||
let plist = replace_exe_path(plist)?;
|
|
||||||
|
|
||||||
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 first so numa is listening before DNS redirect
|
// Load the service
|
||||||
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()
|
||||||
@@ -1000,34 +619,14 @@ fn install_service_macos() -> Result<(), String> {
|
|||||||
return Err("launchctl load failed".to_string());
|
return Err("launchctl load failed".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for numa to be ready before redirecting DNS
|
// Set system DNS to 127.0.0.1 now that the service is running
|
||||||
let api_up = (0..10).any(|i| {
|
eprintln!(" Service installed and started.");
|
||||||
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 uninstall' to restore original DNS.\n");
|
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
|
||||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
|
||||||
eprintln!(" [upstream]");
|
|
||||||
eprintln!(" mode = \"recursive\"\n");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1109,11 +708,8 @@ 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(
|
std::fs::write(&drop_in, "[Resolve]\nDNS=127.0.0.1\nDomains=~.\n")
|
||||||
&drop_in,
|
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
||||||
"[Resolve]\nDNS=127.0.0.1\nDomains=~. numa\nDNSStubListener=no\n",
|
|
||||||
)
|
|
||||||
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
|
||||||
|
|
||||||
let _ = run_systemctl(&["restart", "systemd-resolved"]);
|
let _ = run_systemctl(&["restart", "systemd-resolved"]);
|
||||||
eprintln!(" systemd-resolved detected.");
|
eprintln!(" systemd-resolved detected.");
|
||||||
@@ -1149,7 +745,7 @@ fn install_linux() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let content =
|
let content =
|
||||||
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\nsearch numa\n";
|
"# Generated by Numa — run 'sudo numa uninstall' to restore\nnameserver 127.0.0.1\n";
|
||||||
std::fs::write(resolv, content)
|
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))?;
|
||||||
|
|
||||||
@@ -1188,30 +784,35 @@ fn uninstall_linux() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn ensure_binary_installed() -> Result<(), String> {
|
||||||
|
if !std::path::Path::new("/usr/local/bin/numa").exists() {
|
||||||
|
return Err("numa binary not found at /usr/local/bin/numa. Run: sudo cp target/release/numa /usr/local/bin/numa".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn install_service_linux() -> Result<(), String> {
|
fn install_service_linux() -> Result<(), String> {
|
||||||
|
ensure_binary_installed()?;
|
||||||
|
|
||||||
let unit = include_str!("../numa.service");
|
let unit = include_str!("../numa.service");
|
||||||
let unit = replace_exe_path(unit)?;
|
|
||||||
std::fs::write(SYSTEMD_UNIT, unit)
|
std::fs::write(SYSTEMD_UNIT, unit)
|
||||||
.map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?;
|
.map_err(|e| format!("failed to write {}: {}", SYSTEMD_UNIT, e))?;
|
||||||
|
|
||||||
run_systemctl(&["daemon-reload"])?;
|
run_systemctl(&["daemon-reload"])?;
|
||||||
run_systemctl(&["enable", "numa"])?;
|
run_systemctl(&["enable", "numa"])?;
|
||||||
|
|
||||||
// Configure system DNS before starting numa so resolved releases port 53 first
|
|
||||||
if let Err(e) = install_linux() {
|
|
||||||
eprintln!(" warning: failed to configure system DNS: {}", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
run_systemctl(&["start", "numa"])?;
|
run_systemctl(&["start", "numa"])?;
|
||||||
|
|
||||||
eprintln!(" Service installed and started.");
|
eprintln!(" Service installed and started.");
|
||||||
|
|
||||||
|
// Set system DNS now that the service is running
|
||||||
|
if let Err(e) = install_linux() {
|
||||||
|
eprintln!(" warning: failed to configure system DNS: {}", e);
|
||||||
|
}
|
||||||
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 uninstall' to restore original DNS.\n");
|
eprintln!(" Run 'sudo numa service stop' to fully uninstall.\n");
|
||||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
|
||||||
eprintln!(" [upstream]");
|
|
||||||
eprintln!(" mode = \"recursive\"\n");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1376,76 +977,3 @@ 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]
|
|
||||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
|
||||||
fn replace_exe_path_substitutes_template() {
|
|
||||||
let plist = include_str!("../com.numa.dns.plist");
|
|
||||||
let unit = include_str!("../numa.service");
|
|
||||||
|
|
||||||
assert!(plist.contains("{{exe_path}}"), "plist missing placeholder");
|
|
||||||
assert!(
|
|
||||||
unit.contains("{{exe_path}}"),
|
|
||||||
"unit file missing placeholder"
|
|
||||||
);
|
|
||||||
|
|
||||||
let result = replace_exe_path(plist).expect("replace_exe_path failed for plist");
|
|
||||||
assert!(!result.contains("{{exe_path}}"));
|
|
||||||
|
|
||||||
let result = replace_exe_path(unit).expect("replace_exe_path failed for unit");
|
|
||||||
assert!(!result.contains("{{exe_path}}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[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