Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a70ab0f1b | ||
|
|
0b883d1c0d | ||
|
|
7f46f6271e | ||
|
|
f3ca83246c | ||
|
|
da93a3cde3 | ||
|
|
98da440c84 | ||
|
|
4e5b88496c | ||
|
|
d5f7ce9e2d | ||
|
|
cc704be590 | ||
|
|
ff1200eb10 | ||
|
|
49535568d9 | ||
|
|
cd1beedf38 | ||
|
|
be52e5c305 | ||
|
|
669498e85f | ||
|
|
d325b92e44 | ||
|
|
261fd2e148 | ||
|
|
30e46e549c | ||
|
|
ac49658c2b | ||
|
|
5265f571d0 | ||
|
|
0ebd924825 | ||
|
|
06d4e91cd2 | ||
|
|
71dbb138bc | ||
|
|
fbf3ca6d11 | ||
|
|
a84f2e7f1d | ||
|
|
7aee90c99b | ||
|
|
1304b1c02c | ||
|
|
59397ecce4 | ||
|
|
f849a4d65f | ||
|
|
962b400f4c | ||
|
|
1f4063d5db | ||
|
|
c6bc307f0a | ||
|
|
c5208e934d | ||
|
|
d69b79451e | ||
|
|
0b194256a9 | ||
|
|
e0c1997056 | ||
|
|
9e07064c94 | ||
|
|
43cedf11f7 | ||
|
|
cd6a54c652 | ||
|
|
9f89627c5a | ||
|
|
e7e5c173f2 | ||
|
|
c6b35045d8 | ||
|
|
10f1602803 | ||
|
|
41a97bb930 | ||
|
|
c4e733c8ef | ||
|
|
4020776b8e | ||
|
|
763ba1de91 | ||
|
|
51dc06690e | ||
|
|
fb89b78226 | ||
|
|
64c4d146ec | ||
|
|
9c290b6ef4 | ||
|
|
c836903db5 | ||
|
|
5e5a6544bc | ||
|
|
227af04564 | ||
|
|
4c58ff49b0 | ||
|
|
d261e8bc86 | ||
|
|
2de337ac36 | ||
|
|
5810ee5aac | ||
|
|
06850de728 | ||
|
|
995916d01b | ||
|
|
7aca3b1991 | ||
|
|
b7d64a9707 | ||
|
|
c333705a0e | ||
|
|
50d17ae118 | ||
|
|
5495107c9e | ||
|
|
02e83ccd72 | ||
|
|
ccbf893b92 | ||
|
|
cd90b50d68 | ||
|
|
5866ff1ba1 | ||
|
|
9a3de2f231 | ||
|
|
6fdadd637c | ||
|
|
9041ccc2e1 | ||
|
|
c9f1d98f45 | ||
|
|
6a8e47bbb5 | ||
|
|
de50720834 | ||
|
|
216ec76640 | ||
|
|
08aaebec7e | ||
|
|
3e40f795da | ||
|
|
8dcebaaca6 | ||
|
|
a48809fc25 | ||
|
|
e94e75101f | ||
|
|
32f50cd254 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -37,3 +37,10 @@ jobs:
|
||||
run: cargo build
|
||||
- name: clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: test
|
||||
run: cargo test
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: numa-windows-x86_64
|
||||
path: target/debug/numa.exe
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1143,7 +1143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "numa"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||
edition = "2021"
|
||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||
|
||||
34
README.md
34
README.md
@@ -15,16 +15,32 @@ Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install razvandimescu/tap/numa
|
||||
# or: cargo install numa
|
||||
# or: curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||
|
||||
sudo numa # port 53 requires root
|
||||
# Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh
|
||||
|
||||
# Windows — download from GitHub Releases
|
||||
# All platforms
|
||||
cargo install numa
|
||||
```
|
||||
|
||||
```bash
|
||||
sudo numa # run in foreground (port 53 requires root/admin)
|
||||
```
|
||||
|
||||
Open the dashboard: **http://numa.numa** (or `http://localhost:5380`)
|
||||
|
||||
Set as system DNS: `sudo numa install`
|
||||
Set as system DNS:
|
||||
|
||||
| Platform | Install | Uninstall |
|
||||
|----------|---------|-----------|
|
||||
| macOS | `sudo numa install` | `sudo numa uninstall` |
|
||||
| Linux | `sudo numa install` | `sudo numa uninstall` |
|
||||
| Windows | `numa install` (admin) + reboot | `numa uninstall` (admin) + reboot |
|
||||
|
||||
On macOS and Linux, numa runs as a system service (launchd/systemd). On Windows, numa auto-starts on login via registry.
|
||||
|
||||
## Local Services
|
||||
|
||||
@@ -43,7 +59,13 @@ Add path-based routing (`app.numa/api → :5001`), share services across machine
|
||||
|
||||
385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network — coffee shops, hotels, airports. Travels with your laptop.
|
||||
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -74,7 +96,7 @@ From Machine B: `curl http://api.numa` → proxied to Machine A's port 8000. Ena
|
||||
| Ad blocking | Yes | Yes | — | 385K+ domains |
|
||||
| Web admin UI | Full | Full | — | Dashboard |
|
||||
| Encrypted upstream (DoH) | Needs cloudflared | Yes | — | Native |
|
||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary |
|
||||
| Portable (laptop) | No (appliance) | No (appliance) | Server | Single binary, macOS/Linux/Windows |
|
||||
| Community maturity | 56K stars, 10 years | 33K stars | 20 years | New |
|
||||
|
||||
## Performance
|
||||
|
||||
@@ -1,618 +0,0 @@
|
||||
# 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.
|
||||
18
src/main.rs
18
src/main.rs
@@ -20,6 +20,9 @@ use numa::system_dns::{
|
||||
discover_system_dns, install_service, restart_service, service_status, uninstall_service,
|
||||
};
|
||||
|
||||
const QUAD9_IP: &str = "9.9.9.9";
|
||||
const DOH_FALLBACK: &str = "https://9.9.9.9/dns-query";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> numa::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
|
||||
@@ -126,7 +129,7 @@ async fn main() -> numa::Result<()> {
|
||||
.use_rustls_tls()
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
let url = "https://dns.quad9.net/dns-query".to_string();
|
||||
let url = DOH_FALLBACK.to_string();
|
||||
let label = url.clone();
|
||||
(
|
||||
numa::config::UpstreamMode::Forward,
|
||||
@@ -152,7 +155,7 @@ async fn main() -> numa::Result<()> {
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| {
|
||||
info!("could not detect system DNS, falling back to Quad9 DoH");
|
||||
"https://dns.quad9.net/dns-query".to_string()
|
||||
DOH_FALLBACK.to_string()
|
||||
})
|
||||
} else {
|
||||
config.upstream.address.clone()
|
||||
@@ -478,7 +481,14 @@ async fn main() -> numa::Result<()> {
|
||||
#[allow(clippy::infinite_loop)]
|
||||
loop {
|
||||
let mut buffer = BytePacketBuffer::new();
|
||||
let (_, src_addr) = ctx.socket.recv_from(&mut buffer.buf).await?;
|
||||
let (_, src_addr) = match ctx.socket.recv_from(&mut buffer.buf).await {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::ConnectionReset => {
|
||||
// Windows delivers ICMP port-unreachable as ConnectionReset on UDP sockets
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
let ctx = Arc::clone(&ctx);
|
||||
tokio::spawn(async move {
|
||||
@@ -521,7 +531,7 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
|
||||
let new_addr = dns_info
|
||||
.default_upstream
|
||||
.or_else(numa::system_dns::detect_dhcp_dns)
|
||||
.unwrap_or_else(|| "9.9.9.9".to_string());
|
||||
.unwrap_or_else(|| QUAD9_IP.to_string());
|
||||
if let Ok(new_sock) =
|
||||
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
|
||||
{
|
||||
|
||||
101
src/srtt.rs
101
src/srtt.rs
@@ -47,16 +47,19 @@ impl SrttCache {
|
||||
|
||||
/// Apply time-based decay: each DECAY_AFTER_SECS period halves distance to INITIAL.
|
||||
fn decayed_srtt(entry: &SrttEntry) -> u64 {
|
||||
let age_secs = entry.updated_at.elapsed().as_secs();
|
||||
Self::decay_for_age(entry.srtt_ms, entry.updated_at.elapsed().as_secs())
|
||||
}
|
||||
|
||||
fn decay_for_age(srtt_ms: u64, age_secs: u64) -> u64 {
|
||||
if age_secs > DECAY_AFTER_SECS {
|
||||
let periods = (age_secs / DECAY_AFTER_SECS).min(8);
|
||||
let mut srtt = entry.srtt_ms;
|
||||
let mut srtt = srtt_ms;
|
||||
for _ in 0..periods {
|
||||
srtt = (srtt + INITIAL_SRTT_MS) / 2;
|
||||
}
|
||||
srtt
|
||||
} else {
|
||||
entry.srtt_ms
|
||||
srtt_ms
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,13 +119,6 @@ impl SrttCache {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn set_updated_at(&mut self, ip: IpAddr, at: Instant) {
|
||||
if let Some(entry) = self.entries.get_mut(&ip) {
|
||||
entry.updated_at = at;
|
||||
}
|
||||
}
|
||||
|
||||
fn maybe_evict(&mut self) {
|
||||
if self.entries.len() < MAX_ENTRIES {
|
||||
return;
|
||||
@@ -218,63 +214,41 @@ mod tests {
|
||||
assert_eq!(addrs, original);
|
||||
}
|
||||
|
||||
fn age(secs: u64) -> Instant {
|
||||
Instant::now() - std::time::Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
/// Cache with ip(1) saturated at FAILURE_PENALTY_MS
|
||||
fn saturated_penalty_cache() -> SrttCache {
|
||||
let mut cache = SrttCache::new(true);
|
||||
for _ in 0..30 {
|
||||
cache.record_rtt(ip(1), FAILURE_PENALTY_MS, false);
|
||||
}
|
||||
cache
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_decay_within_threshold() {
|
||||
let mut cache = SrttCache::new(true);
|
||||
cache.record_rtt(ip(1), 5000, false);
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS));
|
||||
assert_eq!(cache.get(ip(1)), cache.entries[&ip(1)].srtt_ms);
|
||||
// At exactly DECAY_AFTER_SECS, no decay applied
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS);
|
||||
assert_eq!(result, FAILURE_PENALTY_MS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_decay_period() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS + 1));
|
||||
let expected = (raw + INITIAL_SRTT_MS) / 2;
|
||||
assert_eq!(cache.get(ip(1)), expected);
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS + 1);
|
||||
let expected = (FAILURE_PENALTY_MS + INITIAL_SRTT_MS) / 2;
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_decay_periods() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
let raw = cache.entries[&ip(1)].srtt_ms;
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 4 + 1));
|
||||
let mut expected = raw;
|
||||
let result = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 4 + 1);
|
||||
let mut expected = FAILURE_PENALTY_MS;
|
||||
for _ in 0..4 {
|
||||
expected = (expected + INITIAL_SRTT_MS) / 2;
|
||||
}
|
||||
assert_eq!(cache.get(ip(1)), expected);
|
||||
assert_eq!(result, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_caps_at_8_periods() {
|
||||
// 9 periods and 100 periods should produce the same result (capped at 8)
|
||||
let mut cache_a = saturated_penalty_cache();
|
||||
let mut cache_b = saturated_penalty_cache();
|
||||
cache_a.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 9 + 1));
|
||||
cache_b.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
assert_eq!(cache_a.get(ip(1)), cache_b.get(ip(1)));
|
||||
let a = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 9 + 1);
|
||||
let b = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_converges_toward_initial() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 100));
|
||||
let decayed = cache.get(ip(1));
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
let diff = decayed.abs_diff(INITIAL_SRTT_MS);
|
||||
assert!(
|
||||
diff < 25,
|
||||
@@ -286,29 +260,28 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn record_rtt_applies_decay_before_ewma() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
cache.set_updated_at(ip(1), age(DECAY_AFTER_SECS * 8));
|
||||
cache.record_rtt(ip(1), 50, false);
|
||||
let srtt = cache.get(ip(1));
|
||||
// Without decay-before-EWMA, result would be ~(5000*7+50)/8 ≈ 4381
|
||||
assert!(srtt < 500, "expected decay before EWMA, got srtt={}", srtt);
|
||||
// Verify decay is applied before EWMA in record_rtt by checking
|
||||
// that a saturated penalty + long age + new sample produces a low SRTT
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 8);
|
||||
// EWMA: (decayed * 7 + 50) / 8
|
||||
let after_ewma = (decayed * 7 + 50) / 8;
|
||||
assert!(
|
||||
after_ewma < 500,
|
||||
"expected decay before EWMA, got srtt={}",
|
||||
after_ewma
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decay_reranks_stale_failures() {
|
||||
let mut cache = saturated_penalty_cache();
|
||||
for _ in 0..30 {
|
||||
cache.record_rtt(ip(2), 300, false);
|
||||
}
|
||||
let mut addrs = vec![sock(1), sock(2)];
|
||||
cache.sort_by_rtt(&mut addrs);
|
||||
assert_eq!(addrs, vec![sock(2), sock(1)]);
|
||||
|
||||
// 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)]);
|
||||
// After enough decay, a failed server (5000ms) converges toward
|
||||
// INITIAL (200ms), which is below a stable server at 300ms
|
||||
let decayed = SrttCache::decay_for_age(FAILURE_PENALTY_MS, DECAY_AFTER_SECS * 100);
|
||||
assert!(
|
||||
decayed < 300,
|
||||
"expected decayed penalty ({}) < 300ms",
|
||||
decayed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -334,7 +334,7 @@ fn discover_windows() -> SystemDnsInfo {
|
||||
if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||
if let Some(ip) = trimmed.split(':').next_back() {
|
||||
let ip = ip.trim();
|
||||
if !is_loopback_or_stub(ip) {
|
||||
if ip.parse::<std::net::IpAddr>().is_ok() && !is_loopback_or_stub(ip) {
|
||||
upstream = Some(ip.to_string());
|
||||
break;
|
||||
}
|
||||
@@ -358,6 +358,339 @@ fn discover_windows() -> SystemDnsInfo {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
|
||||
struct WindowsInterfaceDns {
|
||||
dhcp: bool,
|
||||
servers: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn parse_ipconfig_interfaces(text: &str) -> std::collections::HashMap<String, WindowsInterfaceDns> {
|
||||
let mut interfaces = std::collections::HashMap::new();
|
||||
let mut current_adapter: Option<String> = None;
|
||||
let mut current_dhcp = false;
|
||||
let mut current_dns: Vec<String> = Vec::new();
|
||||
let mut in_dns_block = false;
|
||||
let mut disconnected = false;
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
// Adapter section headers start at column 0
|
||||
if !trimmed.is_empty() && !line.starts_with(' ') && !line.starts_with('\t') {
|
||||
if let Some(name) = current_adapter.take() {
|
||||
if !disconnected {
|
||||
interfaces.insert(
|
||||
name,
|
||||
WindowsInterfaceDns {
|
||||
dhcp: current_dhcp,
|
||||
servers: std::mem::take(&mut current_dns),
|
||||
},
|
||||
);
|
||||
}
|
||||
current_dns.clear();
|
||||
}
|
||||
in_dns_block = false;
|
||||
current_dhcp = false;
|
||||
disconnected = false;
|
||||
|
||||
// "XXX adapter YYY:" (English) / "XXX Adapter YYY:" (German)
|
||||
let lower = trimmed.to_lowercase();
|
||||
if let Some(pos) = lower.find(" adapter ") {
|
||||
let after = &trimmed[pos + " adapter ".len()..];
|
||||
let name = after.trim_end_matches(':').trim();
|
||||
if !name.is_empty() {
|
||||
current_adapter = Some(name.to_string());
|
||||
}
|
||||
}
|
||||
} else if current_adapter.is_some() {
|
||||
if trimmed.contains("Media disconnected") || trimmed.contains("Medienstatus") {
|
||||
disconnected = true;
|
||||
} else if trimmed.contains("DHCP") && trimmed.contains(". .") {
|
||||
current_dhcp = trimmed
|
||||
.split(':')
|
||||
.next_back()
|
||||
.map(|v| {
|
||||
let v = v.trim().to_lowercase();
|
||||
v == "yes" || v == "ja"
|
||||
})
|
||||
.unwrap_or(false);
|
||||
in_dns_block = false;
|
||||
} else if trimmed.contains("DNS Servers") || trimmed.contains("DNS-Server") {
|
||||
in_dns_block = true;
|
||||
if let Some(ip) = trimmed.split(':').next_back() {
|
||||
let ip = ip.trim();
|
||||
if ip.parse::<std::net::IpAddr>().is_ok() {
|
||||
current_dns.push(ip.to_string());
|
||||
}
|
||||
}
|
||||
} else if in_dns_block {
|
||||
if trimmed.parse::<std::net::IpAddr>().is_ok() {
|
||||
current_dns.push(trimmed.to_string());
|
||||
} else {
|
||||
in_dns_block = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = current_adapter {
|
||||
if !disconnected {
|
||||
interfaces.insert(
|
||||
name,
|
||||
WindowsInterfaceDns {
|
||||
dhcp: current_dhcp,
|
||||
servers: current_dns,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interfaces
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn get_windows_interfaces() -> Result<std::collections::HashMap<String, WindowsInterfaceDns>, String>
|
||||
{
|
||||
let output = std::process::Command::new("ipconfig")
|
||||
.arg("/all")
|
||||
.output()
|
||||
.map_err(|e| format!("failed to run ipconfig /all: {}", e))?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(parse_ipconfig_interfaces(&text))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_backup_path() -> std::path::PathBuf {
|
||||
// Use ProgramData (not APPDATA) since install requires admin elevation
|
||||
// and APPDATA differs between user and admin contexts.
|
||||
std::path::PathBuf::from(
|
||||
std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".into()),
|
||||
)
|
||||
.join("numa")
|
||||
.join("original-dns.json")
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn disable_dnscache() -> Result<bool, String> {
|
||||
// Check if Dnscache is running (it holds port 53 at kernel level)
|
||||
let output = std::process::Command::new("sc")
|
||||
.args(["query", "Dnscache"])
|
||||
.output()
|
||||
.map_err(|e| format!("failed to query Dnscache: {}", e))?;
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
if !text.contains("RUNNING") {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
eprintln!(" Disabling DNS Client (Dnscache) to free port 53...");
|
||||
// Dnscache can't be stopped via sc/net stop — must disable via registry
|
||||
let status = std::process::Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
||||
"/v",
|
||||
"Start",
|
||||
"/t",
|
||||
"REG_DWORD",
|
||||
"/d",
|
||||
"4",
|
||||
"/f",
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to disable Dnscache: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("failed to disable Dnscache via registry (run as Administrator?)".into());
|
||||
}
|
||||
|
||||
eprintln!(" Dnscache disabled. A reboot is required to free port 53.");
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn enable_dnscache() {
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKLM\\SYSTEM\\CurrentControlSet\\Services\\Dnscache",
|
||||
"/v",
|
||||
"Start",
|
||||
"/t",
|
||||
"REG_DWORD",
|
||||
"/d",
|
||||
"2",
|
||||
"/f",
|
||||
])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn install_windows() -> Result<(), String> {
|
||||
let interfaces = get_windows_interfaces()?;
|
||||
if interfaces.is_empty() {
|
||||
return Err("no active network interfaces found".to_string());
|
||||
}
|
||||
|
||||
let path = windows_backup_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("failed to create {}: {}", parent.display(), e))?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&interfaces)
|
||||
.map_err(|e| format!("failed to serialize backup: {}", e))?;
|
||||
std::fs::write(&path, json).map_err(|e| format!("failed to write backup: {}", e))?;
|
||||
|
||||
for name in interfaces.keys() {
|
||||
let status = std::process::Command::new("netsh")
|
||||
.args([
|
||||
"interface",
|
||||
"ipv4",
|
||||
"set",
|
||||
"dnsservers",
|
||||
name,
|
||||
"static",
|
||||
"127.0.0.1",
|
||||
"primary",
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to set DNS for {}: {}", name, e))?;
|
||||
|
||||
if status.success() {
|
||||
eprintln!(" set DNS for \"{}\" -> 127.0.0.1", name);
|
||||
} else {
|
||||
eprintln!(
|
||||
" warning: failed to set DNS for \"{}\" (run as Administrator?)",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let needs_reboot = disable_dnscache()?;
|
||||
register_autostart();
|
||||
|
||||
eprintln!("\n Original DNS saved to {}", path.display());
|
||||
eprintln!(" Run 'numa uninstall' to restore.\n");
|
||||
if needs_reboot {
|
||||
eprintln!(" *** Reboot required. Numa will start automatically. ***\n");
|
||||
} else {
|
||||
eprintln!(" Numa will start automatically on next boot.\n");
|
||||
}
|
||||
eprintln!(" Want full DNS sovereignty? Add to numa.toml:");
|
||||
eprintln!(" [upstream]");
|
||||
eprintln!(" mode = \"recursive\"\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register numa to auto-start on boot via registry Run key.
|
||||
#[cfg(windows)]
|
||||
fn register_autostart() {
|
||||
let exe = std::env::current_exe()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "numa".into());
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args([
|
||||
"add",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
||||
"/v",
|
||||
"Numa",
|
||||
"/t",
|
||||
"REG_SZ",
|
||||
"/d",
|
||||
&exe,
|
||||
"/f",
|
||||
])
|
||||
.status();
|
||||
eprintln!(" Registered auto-start on boot.");
|
||||
}
|
||||
|
||||
/// Remove numa auto-start registry key.
|
||||
#[cfg(windows)]
|
||||
fn remove_autostart() {
|
||||
let _ = std::process::Command::new("reg")
|
||||
.args([
|
||||
"delete",
|
||||
"HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run",
|
||||
"/v",
|
||||
"Numa",
|
||||
"/f",
|
||||
])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn uninstall_windows() -> Result<(), String> {
|
||||
remove_autostart();
|
||||
let path = windows_backup_path();
|
||||
let json = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("no backup found at {}: {}", path.display(), e))?;
|
||||
let original: std::collections::HashMap<String, WindowsInterfaceDns> =
|
||||
serde_json::from_str(&json).map_err(|e| format!("invalid backup file: {}", e))?;
|
||||
|
||||
for (name, dns_info) in &original {
|
||||
if dns_info.dhcp || dns_info.servers.is_empty() {
|
||||
let status = std::process::Command::new("netsh")
|
||||
.args(["interface", "ipv4", "set", "dnsservers", name, "dhcp"])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||
|
||||
if status.success() {
|
||||
eprintln!(" restored DNS for \"{}\" -> DHCP", name);
|
||||
} else {
|
||||
eprintln!(" warning: failed to restore DNS for \"{}\"", name);
|
||||
}
|
||||
} else {
|
||||
let status = std::process::Command::new("netsh")
|
||||
.args([
|
||||
"interface",
|
||||
"ipv4",
|
||||
"set",
|
||||
"dnsservers",
|
||||
name,
|
||||
"static",
|
||||
&dns_info.servers[0],
|
||||
"primary",
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| format!("failed to restore DNS for {}: {}", name, e))?;
|
||||
|
||||
if !status.success() {
|
||||
eprintln!(" warning: failed to restore primary DNS for \"{}\"", name);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (i, server) in dns_info.servers.iter().skip(1).enumerate() {
|
||||
let _ = std::process::Command::new("netsh")
|
||||
.args([
|
||||
"interface",
|
||||
"ipv4",
|
||||
"add",
|
||||
"dnsservers",
|
||||
name,
|
||||
server,
|
||||
&format!("index={}", i + 2),
|
||||
])
|
||||
.status();
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
" restored DNS for \"{}\" -> {}",
|
||||
name,
|
||||
dns_info.servers.join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::remove_file(&path).ok();
|
||||
|
||||
// Re-enable Dnscache
|
||||
enable_dnscache();
|
||||
eprintln!("\n System DNS restored. DNS Client re-enabled.");
|
||||
eprintln!(" Reboot to fully restore the DNS Client service.\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the upstream for a domain by checking forwarding rules.
|
||||
/// Returns None if no rule matches (use default upstream).
|
||||
/// Zero-allocation on the hot path — dot_suffix is pre-computed.
|
||||
@@ -522,7 +855,9 @@ pub fn install_service() -> Result<(), String> {
|
||||
let result = install_service_macos();
|
||||
#[cfg(target_os = "linux")]
|
||||
let result = install_service_linux();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
#[cfg(windows)]
|
||||
let result = install_windows();
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
let result = Err::<(), String>("service installation not supported on this OS".to_string());
|
||||
|
||||
if result.is_ok() {
|
||||
@@ -546,7 +881,11 @@ pub fn uninstall_service() -> Result<(), String> {
|
||||
{
|
||||
uninstall_service_linux()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
uninstall_windows()
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
{
|
||||
Err("service uninstallation not supported on this OS".to_string())
|
||||
}
|
||||
@@ -1027,3 +1366,57 @@ fn untrust_ca() -> Result<(), String> {
|
||||
let _ = ca_path; // suppress unused warning on other platforms
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_ipconfig_dhcp_and_static() {
|
||||
let sample = "\
|
||||
Ethernet adapter Ethernet:
|
||||
|
||||
DHCP Enabled. . . . . . . . . . . : Yes
|
||||
DNS Servers . . . . . . . . . . . : 8.8.8.8
|
||||
8.8.4.4
|
||||
|
||||
Wireless LAN adapter Wi-Fi:
|
||||
|
||||
DHCP Enabled. . . . . . . . . . . : No
|
||||
DNS Servers . . . . . . . . . . . : 1.1.1.1
|
||||
";
|
||||
let result = parse_ipconfig_interfaces(sample);
|
||||
assert_eq!(result.len(), 2);
|
||||
assert_eq!(
|
||||
result["Ethernet"],
|
||||
WindowsInterfaceDns {
|
||||
dhcp: true,
|
||||
servers: vec!["8.8.8.8".into(), "8.8.4.4".into()],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
result["Wi-Fi"],
|
||||
WindowsInterfaceDns {
|
||||
dhcp: false,
|
||||
servers: vec!["1.1.1.1".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ipconfig_skips_disconnected() {
|
||||
let sample = "\
|
||||
Ethernet adapter Ethernet 2:
|
||||
|
||||
Media State . . . . . . . . . . . : Media disconnected
|
||||
|
||||
Wireless LAN adapter Wi-Fi:
|
||||
|
||||
DHCP Enabled. . . . . . . . . . . : Yes
|
||||
DNS Servers . . . . . . . . . . . : 192.168.1.1
|
||||
";
|
||||
let result = parse_ipconfig_interfaces(sample);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert!(result.contains_key("Wi-Fi"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user