feat: Windows DNS configuration via netsh #28

Merged
razvandimescu merged 16 commits from feat/windows-dns-config into main 2026-04-01 23:17:52 +08:00
6 changed files with 475 additions and 694 deletions

View File

@@ -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

View File

@@ -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
@@ -80,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

View File

@@ -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.

View File

@@ -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>()
{

View File

@@ -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]

View File

@@ -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"));
}
}