Compare commits
78 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78711f516e | ||
|
|
64d85ce770 | ||
|
|
8791198d10 | ||
|
|
f9b503ab96 | ||
|
|
2b99b39bcc | ||
|
|
7ab97f4cdc | ||
|
|
65dcd9a9c5 | ||
|
|
32cd8624b4 | ||
|
|
bea0affdde | ||
|
|
bad4f25d7d | ||
|
|
5f45e23f55 | ||
|
|
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 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -37,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
|
|
||||||
|
|||||||
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.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
"axum",
|
"axum",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "numa"
|
name = "numa"
|
||||||
version = "0.9.1"
|
version = "0.8.0"
|
||||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -15,32 +15,16 @@ Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by
|
|||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
|
||||||
brew install razvandimescu/tap/numa
|
brew install razvandimescu/tap/numa
|
||||||
|
# or: cargo install numa
|
||||||
|
# or: curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||||
|
|
||||||
# Linux
|
sudo numa # port 53 requires root
|
||||||
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
|
||||||
|
|
||||||
# Windows — download from GitHub Releases
|
|
||||||
# All platforms
|
|
||||||
cargo install numa
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo numa # run in foreground (port 53 requires root/admin)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
|
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
|
||||||
|
|
||||||
Set as system DNS:
|
Set as system DNS: `sudo numa install`
|
||||||
|
|
||||||
| Platform | Install | Uninstall |
|
|
||||||
|----------|---------|-----------|
|
|
||||||
| macOS | `sudo numa install` | `sudo numa uninstall` |
|
|
||||||
| Linux | `sudo numa install` | `sudo numa uninstall` |
|
|
||||||
| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot |
|
|
||||||
|
|
||||||
On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry.
|
|
||||||
|
|
||||||
## Local Services
|
## Local Services
|
||||||
|
|
||||||
@@ -59,13 +43,7 @@ Add path-based routing (`app.numa/api → :5001`), share services across machine
|
|||||||
|
|
||||||
385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop.
|
385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop.
|
||||||
|
|
||||||
Three resolution modes:
|
By default, Numa forwards to your existing system DNS — everything works as before, just with caching and ad blocking on top. For full privacy, set `mode = "recursive"` — Numa resolves directly from root nameservers. No upstream dependency, no single entity sees your full query pattern. DNSSEC validates the full chain of trust: RRSIG signatures, DNSKEY verification, DS delegation, NSEC/NSEC3 denial proofs. [Read how it works →](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||||
|
|
||||||
- **`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
|
## LAN Discovery
|
||||||
|
|
||||||
@@ -96,7 +74,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
|||||||
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
||||||
| Web admin UI | Full | Full | — | Dashboard |
|
| Web admin UI | Full | Full | — | Dashboard |
|
||||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
||||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary |
|
||||||
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
||||||
|
|
||||||
## Performance
|
## Performance
|
||||||
|
|||||||
618
docs/marketing/launch-drafts.md
Normal file
618
docs/marketing/launch-drafts.md
Normal file
@@ -0,0 +1,618 @@
|
|||||||
|
# Launch Drafts
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
**r/selfhosted** (0 upvotes, hostile) — "replaces Pi-hole" framing triggered
|
||||||
|
defensive comparisons. Audience protects their stack.
|
||||||
|
|
||||||
|
**r/programare** (26 upvotes, 22 comments, 12K views, 90.6% ratio) — worked
|
||||||
|
because it led with technical achievement. But: "what does this offer over
|
||||||
|
/etc/hosts?" and "mature solutions exist (dnsmasq, nginx)" were the top
|
||||||
|
objections. Tool-replacement angle falls flat with generalist audiences.
|
||||||
|
|
||||||
|
**r/webdev** — removed by moderators (self-promotion rules).
|
||||||
|
|
||||||
|
Key takeaways:
|
||||||
|
|
||||||
|
- Lead with what's *unique*, not what it *replaces*
|
||||||
|
- Write like explaining to a colleague, not marketing copy
|
||||||
|
- Pick ONE hook per community — don't try to be everything
|
||||||
|
- Triple-check the GitHub link works before posting
|
||||||
|
- Authentic tone > polished bullets
|
||||||
|
- Agree with "just use X" — then show what X can't do
|
||||||
|
- Don't oversell the pkarr/token vision — one sentence max
|
||||||
|
- Benchmark request from r/programare (Mydocalm) — warm follow-up content
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Launch Order
|
||||||
|
|
||||||
|
~~0. **r/programare** — done (2026-03-21). 12K views, 26 upvotes, 22 comments.~~
|
||||||
|
~~1. **r/webdev** — removed by moderators.~~
|
||||||
|
|
||||||
|
~~2. **r/degoogle** — done~~
|
||||||
|
~~3. **r/node** — done~~
|
||||||
|
|
||||||
|
4. **r/coolgithubprojects** — zero friction, just post the repo
|
||||||
|
~~5. **r/sideproject** — done (2026-03-29)~~
|
||||||
|
6. **r/dns** — technical DNS audience, recursive + DNSSEC angle
|
||||||
|
7. **Show HN** — Tuesday-Thursday, 9-10 AM ET
|
||||||
|
8. **r/rust** — same day as HN, technical deep-dive
|
||||||
|
9. **r/commandline** — 24h after HN
|
||||||
|
10. **r/selfhosted** — only if HN hits front page, lead with recursive + LAN discovery
|
||||||
|
11. **r/programare follow-up** — benchmark post + recursive/DNSSEC update
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Community Drafts
|
||||||
|
|
||||||
|
### Show HN
|
||||||
|
|
||||||
|
**Title (72 chars):**
|
||||||
|
Show HN: I built a DNS resolver from scratch in Rust – no DNS libraries
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I wanted to understand how DNS actually works at the wire level, so I built
|
||||||
|
a resolver from scratch. No dns libraries — the RFC 1035 protocol (headers,
|
||||||
|
labels, compression pointers, record types) is all hand-parsed. It started
|
||||||
|
as a learning project and turned into something I use daily as my system DNS.
|
||||||
|
|
||||||
|
What it does today:
|
||||||
|
|
||||||
|
- **Forward mode by default** — transparent proxy to your existing DNS with
|
||||||
|
caching and ad blocking. Changes nothing about your network.
|
||||||
|
- **Full recursive resolver** — set `mode = "recursive"` and it resolves from
|
||||||
|
root nameservers. No upstream dependency. CNAME chasing, TLD priming, SRTT.
|
||||||
|
- **DNSSEC validation** — chain-of-trust verification from root KSK.
|
||||||
|
RSA/SHA-256, ECDSA P-256, Ed25519. Sets the AD bit on verified responses.
|
||||||
|
- **Ad blocking** — ~385K+ domains via Hagezi Pro, works on any network
|
||||||
|
- **DNS-over-HTTPS** — encrypted upstream (Quad9, Cloudflare, or any
|
||||||
|
provider) as an alternative to recursive mode
|
||||||
|
- **`.numa` local domains** — register `frontend.numa → localhost:5173` and
|
||||||
|
it creates both the DNS record and an HTTP/HTTPS reverse proxy with
|
||||||
|
auto-generated TLS certs. WebSocket passthrough works (Vite HMR).
|
||||||
|
- **LAN service discovery** — run Numa on two machines, they find each other
|
||||||
|
via UDP multicast. Zero config.
|
||||||
|
- **Developer overrides** — point any hostname to any IP, auto-reverts
|
||||||
|
after N minutes. REST API for scripting.
|
||||||
|
|
||||||
|
Single binary, macOS + Linux. `sudo numa install` and it's your system DNS —
|
||||||
|
forward mode by default, recursive when you're ready.
|
||||||
|
|
||||||
|
The interesting technical bits: the recursive resolver walks root → TLD →
|
||||||
|
authoritative with iterative queries, caching NS/DS/DNSKEY records at each
|
||||||
|
hop. DNSSEC validation verifies RRSIG signatures against DNSKEY, walks the
|
||||||
|
chain via DS records up to the hardcoded root trust anchor. ECDSA P-256
|
||||||
|
verification takes 174ns (benchmarked with criterion). Cold-cache validation
|
||||||
|
for a new domain is ~90ms, with only 1 network fetch needed (TLD chain is
|
||||||
|
pre-warmed on startup). SRTT-based nameserver selection learns which
|
||||||
|
servers respond fastest — average recursive query drops from 2.8s to
|
||||||
|
237ms after warmup (12x).
|
||||||
|
|
||||||
|
It also handles hostile networks: if your ISP blocks UDP port 53,
|
||||||
|
Numa detects this after 3 failures and switches all
|
||||||
|
queries to TCP automatically. Resets when you change networks. RFC 7816
|
||||||
|
query minimization means root servers only see the TLD, not your full
|
||||||
|
query.
|
||||||
|
|
||||||
|
The DNS cache adjusts TTLs on read (remaining time, not original). Each
|
||||||
|
query is an async tokio task. EDNS0 with DO bit and 1232-byte payload
|
||||||
|
(DNS Flag Day 2020).
|
||||||
|
|
||||||
|
Longer term I want to add pkarr/DHT resolution for self-sovereign DNS,
|
||||||
|
but that's future work.
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/rust
|
||||||
|
|
||||||
|
**Title:** I built a recursive DNS resolver from scratch in Rust — DNSSEC, no DNS libraries
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I've been building a DNS resolver in Rust as a learning project that became
|
||||||
|
my daily driver. The entire DNS wire protocol is implemented by hand —
|
||||||
|
no `trust-dns`, no `hickory-dns`, no `simple-dns`. Headers, label sequences,
|
||||||
|
compression pointers, EDNS, all of it.
|
||||||
|
|
||||||
|
Some things I found interesting while building this:
|
||||||
|
|
||||||
|
**Recursive resolution** — iterative queries from root hints, walking
|
||||||
|
root → TLD → authoritative. CNAME chasing, A+AAAA glue extraction from
|
||||||
|
additional sections, referral depth limits. TLD priming pre-warms NS + DS +
|
||||||
|
DNSKEY for 34 gTLDs + EU ccTLDs on startup.
|
||||||
|
|
||||||
|
**DNSSEC chain-of-trust** — the most involved part. Verify RRSIG signatures
|
||||||
|
against DNSKEY, walk DS records up to the hardcoded root KSK (key tag 20326).
|
||||||
|
Uses `ring` for crypto: RSA/SHA-256, ECDSA P-256 (174ns per verify), Ed25519.
|
||||||
|
RFC 3110 RSA keys need converting to PKCS#1 DER for ring — wrote an ASN.1
|
||||||
|
encoder for that. RRSIG time validity checks per RFC 4035 §5.3.1.
|
||||||
|
|
||||||
|
**NSEC/NSEC3 denial proofs** — proving a name *doesn't* exist is harder than
|
||||||
|
proving it does. NSEC uses canonical DNS name ordering to prove gap coverage.
|
||||||
|
NSEC3 uses iterated SHA-1 hashing + base32hex + a 3-part closest encloser
|
||||||
|
proof (RFC 5155 §8.4). Both require authority-section RRSIG verification.
|
||||||
|
|
||||||
|
**Wire protocol parsing** — DNS uses a binary format with label compression
|
||||||
|
(pointers back into the packet via 2-byte offsets). Parsing this correctly
|
||||||
|
is surprisingly tricky because pointers can chain. I use a `BytePacketBuffer`
|
||||||
|
that tracks position and handles jumps.
|
||||||
|
|
||||||
|
**Performance** — TLD chain pre-warming means cold-cache DNSSEC validation
|
||||||
|
needs ~1 DNSKEY fetch (down from 5). Referral DS piggybacking caches DS
|
||||||
|
from authority sections during resolution. ECDSA P-256 verify: 174ns.
|
||||||
|
RSA/SHA-256: 10.9µs. DS verify: 257ns.
|
||||||
|
|
||||||
|
**LAN service discovery** — Numa instances on the same network find each
|
||||||
|
other via UDP multicast. The tricky part was self-filtering: I initially
|
||||||
|
filtered by IP, but two instances on the same host share an IP. Switched to
|
||||||
|
a per-process instance ID (`pid ^ nanos`).
|
||||||
|
|
||||||
|
**Auto TLS** — generates a local CA + per-service certs using `rcgen`.
|
||||||
|
`numa install` trusts the CA in the OS keychain. HTTPS proxy via `rustls` +
|
||||||
|
`tokio-rustls`.
|
||||||
|
|
||||||
|
Single binary, no runtime dependencies. Uses `tokio`, `axum` (REST
|
||||||
|
API/dashboard), `hyper` (reverse proxy), `ring` (DNSSEC crypto), `reqwest`
|
||||||
|
(DoH), `socket2` (multicast), `rcgen` + `rustls` (TLS).
|
||||||
|
|
||||||
|
Happy to discuss any of the implementation decisions.
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/degoogle
|
||||||
|
|
||||||
|
**Title:** I replaced cloud DNS with a recursive resolver — resolves from root, no upstream, DNSSEC
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I wanted a DNS setup with zero cloud dependency. No NextDNS account,
|
||||||
|
no Cloudflare dashboard, no Pi-hole appliance, no upstream resolver seeing
|
||||||
|
my queries. Just a single binary on my laptop that resolves everything
|
||||||
|
itself.
|
||||||
|
|
||||||
|
Built one in Rust. What it does:
|
||||||
|
|
||||||
|
- **Forward mode by default** — transparent proxy to your existing DNS with
|
||||||
|
caching and ad blocking. Changes nothing about your network.
|
||||||
|
- **Recursive resolution** — set `mode = "recursive"` and it resolves directly
|
||||||
|
from root nameservers. No Quad9, no Cloudflare, no upstream dependency.
|
||||||
|
Each authoritative server only sees the query for its zone — no single
|
||||||
|
entity sees your full browsing pattern.
|
||||||
|
- **DNSSEC validation** — verifies the chain of trust from root KSK.
|
||||||
|
Responses are cryptographically verified — no one can tamper with them
|
||||||
|
in transit.
|
||||||
|
- **System-level ad blocking** — Hagezi Pro list (~385K+ domains),
|
||||||
|
works on any network. Coffee shop WiFi, airport, hotel.
|
||||||
|
- **ISP resistant** — in recursive mode, if UDP is blocked Numa switches
|
||||||
|
to TCP automatically. Or set `mode = "auto"` to probe on startup and
|
||||||
|
fall back to encrypted DoH if needed.
|
||||||
|
- **Query minimization** — root servers only see the TLD (.com), not
|
||||||
|
your full domain. RFC 7816.
|
||||||
|
- **Zero telemetry, zero cloud** — all data stays on your machine. No
|
||||||
|
account, no login, no analytics. Config is a single TOML file.
|
||||||
|
- **Local service naming** — bonus for developers: `https://app.numa`
|
||||||
|
instead of `localhost:3000`, with auto-generated TLS certs
|
||||||
|
|
||||||
|
Single binary, macOS + Linux. `sudo numa install` and it's your system
|
||||||
|
DNS — forward mode by default, recursive when you're ready. No Docker,
|
||||||
|
no PHP, no external dependencies.
|
||||||
|
|
||||||
|
The DNS wire protocol is parsed from scratch — no DNS libraries. You can
|
||||||
|
read every line of code.
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install razvandimescu/tap/numa
|
||||||
|
# or
|
||||||
|
cargo install numa
|
||||||
|
```
|
||||||
|
|
||||||
|
MIT license. https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/node
|
||||||
|
|
||||||
|
**Title:** I replaced localhost:5173 with frontend.numa — auto HTTPS, HMR works, no nginx
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
Running a Vite frontend on :5173, Express API on :3000, maybe docs on
|
||||||
|
:4000 — I could never remember which port was which. And CORS between
|
||||||
|
`localhost:5173` and `localhost:3000` is its own special hell.
|
||||||
|
|
||||||
|
How do you get named domains with HTTPS locally?
|
||||||
|
|
||||||
|
1. /etc/hosts + mkcert + nginx
|
||||||
|
2. dnsmasq + mkcert + Caddy
|
||||||
|
3. `sudo numa`
|
||||||
|
|
||||||
|
What it actually does:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST localhost:5380/services \
|
||||||
|
-d '{"name":"frontend","target_port":5173}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now `https://frontend.numa` works in my browser. Green lock, valid cert.
|
||||||
|
|
||||||
|
- **HMR works** — Vite, webpack, socket.io all pass through the proxy.
|
||||||
|
No special config.
|
||||||
|
- **CORS solved** — `frontend.numa` and `api.numa` share the `.numa`
|
||||||
|
cookie domain. Cross-service auth just works.
|
||||||
|
- **Path routing** — `app.numa/api → :3000`, `app.numa/auth → :3001`.
|
||||||
|
Like nginx location blocks, zero config files.
|
||||||
|
|
||||||
|
No mkcert, no nginx.conf, no Caddyfile, no editing /etc/hosts.
|
||||||
|
Single binary, one command.
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install razvandimescu/tap/numa
|
||||||
|
# or
|
||||||
|
cargo install numa
|
||||||
|
```
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/dns
|
||||||
|
|
||||||
|
**Title:** Numa — recursive DNS resolver from scratch in Rust, DNSSEC, no DNS libraries
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I built a recursive DNS resolver where the entire wire protocol (RFC 1035 —
|
||||||
|
headers, label compression, EDNS0) is hand-parsed. No `hickory-dns`,
|
||||||
|
no `trust-dns`.
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
- Full recursive resolver from root hints (iterative queries, no upstream needed)
|
||||||
|
- DNSSEC chain-of-trust validation (RSA/SHA-256, ECDSA P-256, Ed25519)
|
||||||
|
- EDNS0 with DO bit, 1232-byte payload (DNS Flag Day 2020 compliant)
|
||||||
|
- DNS-over-HTTPS as an alternative upstream mode
|
||||||
|
- Ad blocking (~385K+ domains via Hagezi Pro)
|
||||||
|
- Conditional forwarding (auto-detects Tailscale/VPN split-DNS)
|
||||||
|
- Local zones, ephemeral overrides with auto-revert via REST API
|
||||||
|
|
||||||
|
DNSSEC implementation: DNSKEY/DS/RRSIG record parsing, canonical wire format
|
||||||
|
for signed data, key tag computation (RFC 4034), DS digest verification.
|
||||||
|
Chain walks from zone → TLD → root trust anchor. ECDSA P-256 signature
|
||||||
|
verification in 174ns. TLD chain pre-warmed on startup. Referral DS records
|
||||||
|
piggybacked from authority sections during resolution.
|
||||||
|
|
||||||
|
NSEC/NSEC3 authenticated denial of existence: NXDOMAIN gap proofs, NSEC3
|
||||||
|
closest encloser proofs (3-part per RFC 5155), NODATA type absence proofs,
|
||||||
|
authority-section RRSIG verification. Iteration cap at 500 for NSEC3 DoS
|
||||||
|
prevention.
|
||||||
|
|
||||||
|
What it doesn't do (yet): no authoritative zone serving (AXFR/NOTIFY).
|
||||||
|
|
||||||
|
Single binary, macOS + Linux. MIT license.
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lobsters (invite-only)
|
||||||
|
|
||||||
|
**Title:** Numa — DNS resolver from scratch in Rust, no DNS libraries
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I built a DNS resolver in Rust — RFC 1035 wire protocol parsed by hand,
|
||||||
|
no `trust-dns` or `hickory-dns`. Started as a learning project, became
|
||||||
|
my daily system DNS.
|
||||||
|
|
||||||
|
Beyond resolving, it does local `.numa` domains with auto HTTPS reverse
|
||||||
|
proxy (register `frontend.numa → localhost:5173`, get a green lock and
|
||||||
|
WebSocket passthrough), and LAN service discovery via UDP multicast —
|
||||||
|
two machines running Numa find each other's services automatically.
|
||||||
|
|
||||||
|
Implementation bits I found interesting: DNS label compression (chained
|
||||||
|
2-byte pointers back into the packet), browsers rejecting wildcard certs
|
||||||
|
under single-label TLDs (`*.numa` fails — need per-service SANs), and
|
||||||
|
`SO_REUSEPORT` on macOS for multiple processes binding the same multicast
|
||||||
|
port.
|
||||||
|
|
||||||
|
Set `mode = "recursive"` for DNSSEC-validated resolution from root
|
||||||
|
nameservers — no upstream, no middleman.
|
||||||
|
|
||||||
|
Single binary, macOS + Linux.
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/coolgithubprojects
|
||||||
|
|
||||||
|
**Post type:** Image post with `hero-demo.gif`, GitHub link in first comment.
|
||||||
|
|
||||||
|
**Title:** Numa — portable DNS resolver built from scratch in Rust. Ad blocking, local HTTPS domains, LAN discovery, recursive resolution with DNSSEC. Single binary.
|
||||||
|
|
||||||
|
**First comment (post immediately):**
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install razvandimescu/tap/numa && sudo numa
|
||||||
|
```
|
||||||
|
|
||||||
|
No DNS libraries — RFC 1035 wire protocol parsed by hand.
|
||||||
|
Recursive resolution from root nameservers with full DNSSEC
|
||||||
|
chain-of-trust validation. 385K+ blocked ad domains.
|
||||||
|
.numa local domains with auto TLS and WebSocket proxy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/sideproject
|
||||||
|
|
||||||
|
**Title:** I built a DNS resolver from scratch in Rust — it's now my daily system DNS
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
Last year I wanted to understand how DNS actually works at the wire
|
||||||
|
level, so I started parsing RFC 1035 packets by hand. No DNS libraries,
|
||||||
|
no trust-dns, no hickory-dns — just bytes and the spec.
|
||||||
|
|
||||||
|
It turned into something I use every day. What it does now:
|
||||||
|
|
||||||
|
- **Ad blocking** on any network (coffee shops, airports) — 385K+
|
||||||
|
domains blocked, travels with my laptop
|
||||||
|
- **Local service naming** — `https://frontend.numa` instead of
|
||||||
|
`localhost:5173`, with auto-generated TLS certs and WebSocket
|
||||||
|
passthrough for HMR
|
||||||
|
- **Recursive resolution** from root nameservers with DNSSEC
|
||||||
|
chain-of-trust validation — set `mode = "recursive"` for full
|
||||||
|
privacy, no upstream dependency, no single entity sees my query
|
||||||
|
pattern
|
||||||
|
- **LAN discovery** — two machines running Numa find each other's
|
||||||
|
services automatically via mDNS
|
||||||
|
|
||||||
|
Single Rust binary, ~8MB, MIT license. `sudo numa install` and it's your
|
||||||
|
system DNS — caching, ad blocking, .numa domains, zero config changes.
|
||||||
|
|
||||||
|
I wrote about the technical journey here:
|
||||||
|
- [I Built a DNS Resolver from Scratch](https://numa.rs/blog/posts/dns-from-scratch.html)
|
||||||
|
- [Implementing DNSSEC from Scratch](https://numa.rs/blog/posts/dnssec-from-scratch.html)
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/webdev (Showoff Saturday — posted 2026-03-28)
|
||||||
|
|
||||||
|
**Title:** I replaced localhost:5173 with frontend.numa — shared cookie domain, auto HTTPS, no nginx
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
The port numbers weren't the real problem. It was CORS between
|
||||||
|
`localhost:5173` and `localhost:3000`, Secure cookies not setting over
|
||||||
|
HTTP, and service workers requiring a secure context.
|
||||||
|
|
||||||
|
I built a DNS resolver that gives local services named domains under a
|
||||||
|
shared TLD:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST localhost:5380/services \
|
||||||
|
-d '{"name":"frontend","target_port":5173}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now `https://frontend.numa` and `https://api.numa` share the `.numa`
|
||||||
|
cookie domain. Cross-service auth just works. Secure cookies set.
|
||||||
|
Service workers run.
|
||||||
|
|
||||||
|
What's under the hood:
|
||||||
|
- **Auto HTTPS** — generates a local CA + per-service TLS certs. Green
|
||||||
|
lock, no mkcert.
|
||||||
|
- **WebSocket passthrough** — Vite/webpack HMR goes through the proxy.
|
||||||
|
No special config.
|
||||||
|
- **Path routing** — `app.numa/api → :3000`, `app.numa/auth → :3001`.
|
||||||
|
Like nginx location blocks.
|
||||||
|
- **Also a full DNS resolver** — forward mode with caching and ad
|
||||||
|
blocking by default. Set `mode = "recursive"` for full DNSSEC-validated
|
||||||
|
resolution from root nameservers.
|
||||||
|
|
||||||
|
Single Rust binary. `sudo numa install` and it's your system DNS — caching,
|
||||||
|
ad blocking, .numa domains. No nginx, no Caddy, no /etc/hosts.
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install razvandimescu/tap/numa
|
||||||
|
# or
|
||||||
|
cargo install numa
|
||||||
|
```
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
**Lessons from r/node (2026-03-24):** "Can't remember 3 ports?" got
|
||||||
|
pushback — the CORS/cookie angle resonated more. Lead with what you
|
||||||
|
can't do without it, not what's annoying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/commandline
|
||||||
|
|
||||||
|
**Title:** numa — local dev DNS with auto HTTPS and LAN service discovery, single Rust binary
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I run 5-6 local services and wanted named domains with HTTPS instead of
|
||||||
|
remembering port numbers. Built a DNS resolver that handles `.numa`
|
||||||
|
domains:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST localhost:5380/services \
|
||||||
|
-d '{"name":"api","target_port":8000}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Now `https://api.numa` resolves, proxies to localhost:8000, and has a
|
||||||
|
valid TLS cert. WebSocket passthrough works — Vite HMR goes through
|
||||||
|
the proxy fine.
|
||||||
|
|
||||||
|
The part I didn't expect to be useful: LAN service discovery. Two
|
||||||
|
machines running numa find each other via UDP multicast. I register
|
||||||
|
`api.numa` on my laptop, my teammate's numa instance picks it up
|
||||||
|
automatically. Zero config.
|
||||||
|
|
||||||
|
Also blocks ~385K+ ad domains since it's already your DNS resolver.
|
||||||
|
Portable — works on any network (coffee shops, airports). Set
|
||||||
|
`mode = "recursive"` for full DNSSEC-validated resolution from root
|
||||||
|
nameservers — no upstream dependency.
|
||||||
|
|
||||||
|
```
|
||||||
|
brew install razvandimescu/tap/numa
|
||||||
|
sudo numa
|
||||||
|
```
|
||||||
|
|
||||||
|
Single binary, DNS wire protocol parsed from scratch (no DNS libraries).
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### r/selfhosted (only if Show HN hits front page)
|
||||||
|
|
||||||
|
**Title:** Numa — recursive resolver + ad blocking + LAN service discovery in one binary
|
||||||
|
|
||||||
|
**Body:**
|
||||||
|
|
||||||
|
I built a DNS resolver in Rust that I've been running as my system DNS.
|
||||||
|
Two features I'm most proud of:
|
||||||
|
|
||||||
|
**Recursive resolution + DNSSEC** — set `mode = "recursive"` and it resolves
|
||||||
|
from root nameservers, no upstream dependency. Chain-of-trust verification
|
||||||
|
(RSA, ECDSA, Ed25519), NSEC/NSEC3 denial proofs. No single entity sees your
|
||||||
|
full query pattern — each authoritative server only sees its zone's queries.
|
||||||
|
|
||||||
|
**LAN service discovery** — I register `api.numa → localhost:8000` on my
|
||||||
|
laptop. My colleague's machine, also running Numa, picks it up via UDP
|
||||||
|
multicast — `api.numa` resolves to my IP on his machine. Zero config.
|
||||||
|
|
||||||
|
The rest of what it does:
|
||||||
|
- **Ad blocking** — ~385K+ domains (Hagezi Pro), portable. Works on any
|
||||||
|
network including coffee shops and airports.
|
||||||
|
- **DNS-over-HTTPS** — encrypted upstream as an alternative to recursive mode.
|
||||||
|
- **Auto HTTPS for local services** — generates a local CA + per-service
|
||||||
|
TLS certs. `https://frontend.numa` with a green lock, WebSocket passthrough.
|
||||||
|
- **Hub mode** — point other devices' DNS to it, they get ad blocking +
|
||||||
|
`.numa` resolution without installing anything.
|
||||||
|
|
||||||
|
Replaces Pi-hole + Unbound in one binary. No Raspberry Pi, no Docker, no PHP.
|
||||||
|
|
||||||
|
Single binary, macOS + Linux. Config is one optional TOML file.
|
||||||
|
|
||||||
|
**What it doesn't do (yet):** No web-based config editor (TOML + REST API).
|
||||||
|
DoT listener is in progress.
|
||||||
|
|
||||||
|
`brew install razvandimescu/tap/numa` or `cargo install numa`
|
||||||
|
|
||||||
|
https://github.com/razvandimescu/numa
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Preparation Checklist
|
||||||
|
|
||||||
|
- [ ] Verify GitHub repo is PUBLIC before any post
|
||||||
|
- [ ] Build some comment history on posting account first
|
||||||
|
- [ ] Post HN Tuesday-Thursday, 9-10 AM Eastern
|
||||||
|
- [ ] Respond to every comment within 2 hours for the first 6 hours
|
||||||
|
- [ ] Have fixes ready to ship within 24h for reported issues
|
||||||
|
- [ ] Don't oversell the pkarr/token vision — one sentence max
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Verify GitHub repo is PUBLIC before every post
|
||||||
|
- Use an account with comment history, not a fresh one
|
||||||
|
- Respond to every comment within 2 hours
|
||||||
|
- Never be defensive — acknowledge valid criticism, redirect
|
||||||
|
- If someone says "just use X" — agree it works, explain what's *uniquely different*
|
||||||
|
- Lead with unique capabilities, not tool replacement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prepared Responses
|
||||||
|
|
||||||
|
**"What does this offer over /etc/hosts?"** *(actual r/programare objection)*
|
||||||
|
/etc/hosts is static and per-machine. Numa gives you: auto-revert after N
|
||||||
|
minutes (great for testing), a REST API so scripts can create/remove entries,
|
||||||
|
HTTPS reverse proxy with auto TLS, and LAN discovery so you don't have to
|
||||||
|
edit hosts on every device. Different tools for different problems.
|
||||||
|
|
||||||
|
**"Mature solutions already exist (dnsmasq, nginx, etc.)"** *(actual r/programare objection)*
|
||||||
|
Absolutely — and they're great. The thing they don't do: register a service
|
||||||
|
on machine A and have it automatically appear on machine B via multicast.
|
||||||
|
Numa integrates DNS + reverse proxy + TLS + discovery into one binary so
|
||||||
|
those pieces work together. If you only need DNS forwarding, dnsmasq is the
|
||||||
|
right tool.
|
||||||
|
|
||||||
|
**"Why not Pi-hole / AdGuard Home?"**
|
||||||
|
They're network appliances — need dedicated hardware or Docker. Numa is a
|
||||||
|
single binary on your laptop. When you move to a coffee shop, your ad
|
||||||
|
blocking comes with you. Plus the reverse proxy + LAN discovery.
|
||||||
|
|
||||||
|
**"Why from scratch / no DNS libraries?"**
|
||||||
|
Started as a learning project to understand the wire protocol. Turned out
|
||||||
|
having full control over the pipeline makes features like conditional
|
||||||
|
forwarding and override injection trivial — they're just steps in the
|
||||||
|
resolution chain.
|
||||||
|
|
||||||
|
**"Vibe coded / AI generated?"**
|
||||||
|
I use AI as a coding partner — same as using Stack Overflow or pair
|
||||||
|
programming. I make the architecture decisions, direct what gets built,
|
||||||
|
and review everything. The DNS wire protocol parser was the original
|
||||||
|
learning project I wrote by hand. Later features were built collaboratively
|
||||||
|
with AI assistance. You can read every line — nothing is opaque generated
|
||||||
|
slop.
|
||||||
|
|
||||||
|
**"Why sudo / why port 53?"**
|
||||||
|
Port 53 requires root on Unix. Numa only needs it for the UDP socket.
|
||||||
|
You can also bind to a high port for testing: `bind_addr = "127.0.0.1:5353"`.
|
||||||
|
|
||||||
|
**"What about .numa TLD conflicts?"**
|
||||||
|
The TLD is configurable in `numa.toml`. If `.numa` ever becomes official,
|
||||||
|
change it to anything else.
|
||||||
|
|
||||||
|
**"Does it support DoH/DoT?"**
|
||||||
|
DoH is built in — set `address = "https://9.9.9.9/dns-query"` in
|
||||||
|
`[upstream]` and your queries are encrypted. Or set `mode = "auto"` to
|
||||||
|
probe root servers and fall back to DoH if blocked. DoT listener support
|
||||||
|
is in progress (PR #25).
|
||||||
|
|
||||||
|
**"But Quad9/Cloudflare still sees my queries"**
|
||||||
|
In forward mode (the default), yes — your upstream resolver sees your queries.
|
||||||
|
Set `mode = "recursive"` and Numa resolves directly from root nameservers —
|
||||||
|
no single upstream sees your full query pattern. Each authoritative server
|
||||||
|
only sees the query relevant to its zone. Add `[dnssec] enabled = true` to
|
||||||
|
cryptographically verify responses.
|
||||||
|
|
||||||
|
**"Show me benchmarks / performance numbers"** *(actual r/programare request)*
|
||||||
|
Benchmark suite is in `benches/` (criterion). Cached round-trip: 691ns.
|
||||||
|
Pipeline throughput: ~2.0M qps. DNSSEC: ECDSA P-256 verify 174ns, RSA/SHA-256
|
||||||
|
10.9µs, DS verify 257ns. Cold-cache DNSSEC validation ~90ms (1 network fetch,
|
||||||
|
TLD chain pre-warmed). Full comparison against system resolver, Quad9,
|
||||||
|
Cloudflare, Google on the site.
|
||||||
|
|
||||||
|
**"Why not just use Unbound?"**
|
||||||
|
Numa supports recursive resolution with DNSSEC validation, same as Unbound
|
||||||
|
(`mode = "recursive"`). The difference:
|
||||||
|
Numa also has built-in ad blocking, a dashboard, `.numa` local domains with
|
||||||
|
auto HTTPS, LAN service discovery, and developer overrides. Unbound does
|
||||||
|
one thing well; Numa integrates six features into one binary.
|
||||||
|
|
||||||
|
**"Why not Technitium?"**
|
||||||
|
Technitium is the closest in features — recursive, DNSSEC, ad blocking,
|
||||||
|
dashboard. Good tool. Two differences: (1) Numa is a single static binary,
|
||||||
|
Technitium requires the .NET runtime; (2) Numa has developer tooling that
|
||||||
|
Technitium doesn't — `.numa` local domains with auto TLS reverse proxy,
|
||||||
|
path-based routing, LAN service discovery, ephemeral overrides with
|
||||||
|
auto-revert. Different audiences: Technitium targets server admins, Numa
|
||||||
|
targets developers on laptops.
|
||||||
|
|
||||||
|
**"Does it support Windows?"**
|
||||||
|
macOS and Linux are the primary targets. Windows has scaffolding in the code
|
||||||
|
but is not tested. If there's demand, it's on the list.
|
||||||
23
src/ctx.rs
23
src/ctx.rs
@@ -162,29 +162,6 @@ 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 (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
|
||||||
|
|||||||
18
src/main.rs
18
src/main.rs
@@ -20,9 +20,6 @@ use numa::system_dns::{
|
|||||||
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUAD9_IP: &str = "9.9.9.9";
|
|
||||||
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[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"))
|
||||||
@@ -129,7 +126,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
.use_rustls_tls()
|
.use_rustls_tls()
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let url = DOH_FALLBACK.to_string();
|
let url = "https://dns.quad9.net/dns-query".to_string();
|
||||||
let label = url.clone();
|
let label = url.clone();
|
||||||
(
|
(
|
||||||
numa::config::UpstreamMode::Forward,
|
numa::config::UpstreamMode::Forward,
|
||||||
@@ -155,7 +152,7 @@ async fn main() -> numa::Result<()> {
|
|||||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||||
DOH_FALLBACK.to_string()
|
"https://dns.quad9.net/dns-query".to_string()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
config.upstream.address.clone()
|
config.upstream.address.clone()
|
||||||
@@ -481,14 +478,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 +521,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>()
|
||||||
{
|
{
|
||||||
|
|||||||
101
src/srtt.rs
101
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,6 +116,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 +218,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,28 +286,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,
|
let mut addrs = vec![sock(1), sock(2)];
|
||||||
"expected decayed penalty ({}) < 300ms",
|
cache.sort_by_rtt(&mut addrs);
|
||||||
decayed
|
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]
|
||||||
|
|||||||
@@ -334,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||||
if let Some(ip) = trimmed.split(':').next_back() {
|
if let Some(ip) = trimmed.split(':').next_back() {
|
||||||
let ip = ip.trim();
|
let ip = ip.trim();
|
||||||
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
if !is_loopback_or_stub(ip) {
|
||||||
upstream = Some(ip.to_string());
|
upstream = Some(ip.to_string());
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -358,339 +358,6 @@ fn discover_windows() -> SystemDnsInfo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
|
||||||
struct WindowsInterfaceDns {
|
|
||||||
dhcp: bool,
|
|
||||||
servers: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(any(windows, test))]
|
|
||||||
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
|
|
||||||
let mut interfaces = std::collections::HashMap::new();
|
|
||||||
let mut current_adapter: Option<String> = None;
|
|
||||||
let mut current_dhcp = false;
|
|
||||||
let mut current_dns: Vec<String> = Vec::new();
|
|
||||||
let mut in_dns_block = false;
|
|
||||||
let mut disconnected = false;
|
|
||||||
|
|
||||||
for line in text.lines() {
|
|
||||||
let trimmed = line.trim();
|
|
||||||
|
|
||||||
// Adapter section headers start at column 0
|
|
||||||
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
|
|
||||||
if let Some(name) = current_adapter.take() {
|
|
||||||
if !disconnected {
|
|
||||||
interfaces.insert(
|
|
||||||
name,
|
|
||||||
WindowsInterfaceDns {
|
|
||||||
dhcp: current_dhcp,
|
|
||||||
servers: std::mem::take(&mut current_dns),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
current_dns.clear();
|
|
||||||
}
|
|
||||||
in_dns_block = false;
|
|
||||||
current_dhcp = false;
|
|
||||||
disconnected = false;
|
|
||||||
|
|
||||||
// "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German)
|
|
||||||
let lower = trimmed.to_lowercase();
|
|
||||||
if let Some(pos) = lower.find(" adapter ") {
|
|
||||||
let after = &trimmed[pos + " adapter ".len()..];
|
|
||||||
let name = after.trim_end_matches(':').trim();
|
|
||||||
if !name.is_empty() {
|
|
||||||
current_adapter = Some(name.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if current_adapter.is_some() {
|
|
||||||
if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") {
|
|
||||||
disconnected = true;
|
|
||||||
} else if trimmed.contains("DHCP") && trimmed.contains(". .") {
|
|
||||||
current_dhcp = trimmed
|
|
||||||
.split(':')
|
|
||||||
.next_back()
|
|
||||||
.map(|v| {
|
|
||||||
let v = v.trim().to_lowercase();
|
|
||||||
v == "yes" || v == "ja"
|
|
||||||
})
|
|
||||||
.unwrap_or(false);
|
|
||||||
in_dns_block = false;
|
|
||||||
} else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
|
||||||
in_dns_block = true;
|
|
||||||
if let Some(ip) = trimmed.split(':').next_back() {
|
|
||||||
let ip = ip.trim();
|
|
||||||
if ip.parse::<std::net::IpAddr>().is_ok() {
|
|
||||||
current_dns.push(ip.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if in_dns_block {
|
|
||||||
if trimmed.parse::<std::net::IpAddr>().is_ok() {
|
|
||||||
current_dns.push(trimmed.to_string());
|
|
||||||
} else {
|
|
||||||
in_dns_block = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(name) = current_adapter {
|
|
||||||
if !disconnected {
|
|
||||||
interfaces.insert(
|
|
||||||
name,
|
|
||||||
WindowsInterfaceDns {
|
|
||||||
dhcp: current_dhcp,
|
|
||||||
servers: current_dns,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interfaces
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn get_windows_interfaces() -> Result<std::collections::HashMap<String, WindowsInterfaceDns>, String>
|
|
||||||
{
|
|
||||||
let output = std::process::Command::new("ipconfig")
|
|
||||||
.arg("/all")
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("failed to run ipconfig /all: {}", e))?;
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
Ok(parse_ipconfig_interfaces(&text))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn windows_backup_path() -> std::path::PathBuf {
|
|
||||||
// Use ProgramData (not APPDATA) since install requires admin elevation
|
|
||||||
// and APPDATA differs between user and admin contexts.
|
|
||||||
std::path::PathBuf::from(
|
|
||||||
std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()),
|
|
||||||
)
|
|
||||||
.join("numa")
|
|
||||||
.join("original-dns.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn disable_dnscache() -> Result<bool, String> {
|
|
||||||
// Check if Dnscache is running (it holds port 53 at kernel level)
|
|
||||||
let output = std::process::Command::new("sc")
|
|
||||||
.args(["query", "Dnscache"])
|
|
||||||
.output()
|
|
||||||
.map_err(|e| format!("failed to query Dnscache: {}", e))?;
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
if !text.contains("RUNNING") {
|
|
||||||
return Ok(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(" Disabling DNS Client (Dnscache) to free port 53...");
|
|
||||||
// Dnscache can't be stopped via sc/net stop — must disable via registry
|
|
||||||
let status = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"add",
|
|
||||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
|
||||||
"/v",
|
|
||||||
"Start",
|
|
||||||
"/t",
|
|
||||||
"REG_DWORD",
|
|
||||||
"/d",
|
|
||||||
"4",
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to disable Dnscache: {}", e))?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
return Err("failed to disable Dnscache via registry (run as Administrator?)".into());
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(" Dnscache disabled. A reboot is required to free port 53.");
|
|
||||||
Ok(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn enable_dnscache() {
|
|
||||||
let _ = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"add",
|
|
||||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
|
||||||
"/v",
|
|
||||||
"Start",
|
|
||||||
"/t",
|
|
||||||
"REG_DWORD",
|
|
||||||
"/d",
|
|
||||||
"2",
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn install_windows() -> Result<(), String> {
|
|
||||||
let interfaces = get_windows_interfaces()?;
|
|
||||||
if interfaces.is_empty() {
|
|
||||||
return Err("no active network interfaces found".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let path = windows_backup_path();
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)
|
|
||||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
|
||||||
}
|
|
||||||
let json = serde_json::to_string_pretty(&interfaces)
|
|
||||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
|
||||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
|
||||||
|
|
||||||
for name in interfaces.keys() {
|
|
||||||
let status = std::process::Command::new("netsh")
|
|
||||||
.args([
|
|
||||||
"interface",
|
|
||||||
"ipv4",
|
|
||||||
"set",
|
|
||||||
"dnsservers",
|
|
||||||
name,
|
|
||||||
"static",
|
|
||||||
"127.0.0.1",
|
|
||||||
"primary",
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to set DNS for {}: {}", name, e))?;
|
|
||||||
|
|
||||||
if status.success() {
|
|
||||||
eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name);
|
|
||||||
} else {
|
|
||||||
eprintln!(
|
|
||||||
" warning: failed to set DNS for \"{}\" (run as Administrator?)",
|
|
||||||
name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let needs_reboot = disable_dnscache()?;
|
|
||||||
register_autostart();
|
|
||||||
|
|
||||||
eprintln!("\n Original DNS saved to {}", path.display());
|
|
||||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
|
||||||
if needs_reboot {
|
|
||||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
|
||||||
} else {
|
|
||||||
eprintln!(" Numa will start automatically on next boot.\n");
|
|
||||||
}
|
|
||||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
|
||||||
eprintln!(" [upstream]");
|
|
||||||
eprintln!(" mode = \"recursive\"\n");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Register numa to auto-start on boot via registry Run key.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn register_autostart() {
|
|
||||||
let exe = std::env::current_exe()
|
|
||||||
.map(|p| p.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|_| "numa".into());
|
|
||||||
let _ = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"add",
|
|
||||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
||||||
"/v",
|
|
||||||
"Numa",
|
|
||||||
"/t",
|
|
||||||
"REG_SZ",
|
|
||||||
"/d",
|
|
||||||
&exe,
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
eprintln!(" Registered auto-start on boot.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove numa auto-start registry key.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn remove_autostart() {
|
|
||||||
let _ = std::process::Command::new("reg")
|
|
||||||
.args([
|
|
||||||
"delete",
|
|
||||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
|
||||||
"/v",
|
|
||||||
"Numa",
|
|
||||||
"/f",
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn uninstall_windows() -> Result<(), String> {
|
|
||||||
remove_autostart();
|
|
||||||
let path = windows_backup_path();
|
|
||||||
let json = std::fs::read_to_string(&path)
|
|
||||||
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
|
|
||||||
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
|
|
||||||
serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?;
|
|
||||||
|
|
||||||
for (name, dns_info) in &original {
|
|
||||||
if dns_info.dhcp || dns_info.servers.is_empty() {
|
|
||||||
let status = std::process::Command::new("netsh")
|
|
||||||
.args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
|
||||||
|
|
||||||
if status.success() {
|
|
||||||
eprintln!(" restored DNS for \"{}\" -> DHCP", name);
|
|
||||||
} else {
|
|
||||||
eprintln!(" warning: failed to restore DNS for \"{}\"", name);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let status = std::process::Command::new("netsh")
|
|
||||||
.args([
|
|
||||||
"interface",
|
|
||||||
"ipv4",
|
|
||||||
"set",
|
|
||||||
"dnsservers",
|
|
||||||
name,
|
|
||||||
"static",
|
|
||||||
&dns_info.servers[0],
|
|
||||||
"primary",
|
|
||||||
])
|
|
||||||
.status()
|
|
||||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
|
||||||
|
|
||||||
if !status.success() {
|
|
||||||
eprintln!(" warning: failed to restore primary DNS for \"{}\"", name);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i, server) in dns_info.servers.iter().skip(1).enumerate() {
|
|
||||||
let _ = std::process::Command::new("netsh")
|
|
||||||
.args([
|
|
||||||
"interface",
|
|
||||||
"ipv4",
|
|
||||||
"add",
|
|
||||||
"dnsservers",
|
|
||||||
name,
|
|
||||||
server,
|
|
||||||
&format!("index={}", i + 2),
|
|
||||||
])
|
|
||||||
.status();
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!(
|
|
||||||
" restored DNS for \"{}\" -> {}",
|
|
||||||
name,
|
|
||||||
dns_info.servers.join(", ")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
std::fs::remove_file(&path).ok();
|
|
||||||
|
|
||||||
// Re-enable Dnscache
|
|
||||||
enable_dnscache();
|
|
||||||
eprintln!("\n System DNS restored. DNS Client re-enabled.");
|
|
||||||
eprintln!(" Reboot to fully restore the DNS Client service.\n");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the upstream for a domain by checking forwarding rules.
|
/// 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.
|
||||||
@@ -776,7 +443,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 +455,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 +499,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();
|
||||||
@@ -865,9 +522,7 @@ pub fn install_service() -> Result<(), String> {
|
|||||||
let result = install_service_macos();
|
let result = install_service_macos();
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
let result = install_service_linux();
|
let result = install_service_linux();
|
||||||
#[cfg(windows)]
|
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||||
let result = install_windows();
|
|
||||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
|
||||||
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
||||||
|
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
@@ -891,11 +546,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())
|
||||||
}
|
}
|
||||||
@@ -1102,7 +753,7 @@ fn install_linux() -> Result<(), String> {
|
|||||||
let drop_in = resolved_dir.join("numa.conf");
|
let drop_in = resolved_dir.join("numa.conf");
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
&drop_in,
|
&drop_in,
|
||||||
"[Resolve]\nDNS=127.0.0.1\nDomains=~. numa\nDNSStubListener=no\n",
|
"[Resolve]\nDNS=127.0.0.1\nDomains=~.\nDNSStubListener=no\n",
|
||||||
)
|
)
|
||||||
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
.map_err(|e| format!("failed to write {}: {}", drop_in.display(), e))?;
|
||||||
|
|
||||||
@@ -1140,7 +791,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))?;
|
||||||
|
|
||||||
@@ -1376,57 +1027,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]
|
|
||||||
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