From 4b60a4b49c6ce1cdfabca918dbadcb5c929ca80d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 21 Mar 2026 03:31:15 +0200 Subject: [PATCH 1/4] launch hardening: TC bit, Dockerfile, platform-aware deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Set TC (truncation) bit when response exceeds 4096-byte buffer instead of dropping the response silently. Clients can retry via TCP. - Log when upstream response is truncated in forward.rs. - Dockerfile: bump to Rust 1.88, include site/service files, use alpine runtime instead of scratch, add cmake/perl for aws-lc-sys. - Makefile deploy: platform-aware — codesign on macOS, systemctl on Linux. - README: trim roadmap to near-term items only. - Verified: Docker build + smoke test passes on Linux (Alpine musl). Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + Dockerfile | 14 ++++++++------ Makefile | 4 ++++ README.md | 5 +---- src/ctx.rs | 13 +++++++++++-- src/forward.rs | 6 +++++- 6 files changed, 30 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index cfa6940..45c6531 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target CLAUDE.md +docs diff --git a/Dockerfile b/Dockerfile index 9b0a102..0af2ee3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,17 @@ -FROM rust:1.85-alpine AS builder -RUN apk add --no-cache musl-dev +FROM rust:1.88-alpine AS builder +RUN apk add --no-cache musl-dev cmake make perl WORKDIR /app COPY Cargo.toml Cargo.lock ./ RUN mkdir src && echo 'fn main() {}' > src/main.rs && echo '' > src/lib.rs RUN cargo build --release 2>/dev/null || true RUN rm -rf src COPY src/ src/ +COPY site/ site/ +COPY numa.toml com.numa.dns.plist numa.service ./ RUN touch src/main.rs src/lib.rs RUN cargo build --release -FROM scratch -COPY --from=builder /app/target/release/numa /numa -EXPOSE 53/udp 5380/tcp -ENTRYPOINT ["/numa"] +FROM alpine:3.20 +COPY --from=builder /app/target/release/numa /usr/local/bin/numa +EXPOSE 53/udp 80/tcp 443/tcp 5380/tcp +ENTRYPOINT ["numa"] diff --git a/Makefile b/Makefile index c83f5aa..5b0165c 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,11 @@ clean: deploy: cargo build --release sudo cp target/release/numa /usr/local/bin/numa +ifeq ($(shell uname -s),Darwin) sudo codesign -f -s - /usr/local/bin/numa sudo kill $$(pgrep -f /usr/local/bin/numa) 2>/dev/null || true +else + sudo systemctl restart numa 2>/dev/null || sudo kill $$(pgrep -f /usr/local/bin/numa) 2>/dev/null || true +endif @sleep 1 @dig @127.0.0.1 google.com +short +time=3 > /dev/null && echo "Service restarted successfully" || echo "Warning: DNS not responding yet" diff --git a/README.md b/README.md index e474159..7ba4813 100644 --- a/README.md +++ b/README.md @@ -230,11 +230,8 @@ Zero external DNS libraries. RFC 1035 wire protocol parsed by hand. Dependencies - [x] System DNS auto-discovery — Tailscale, VPN split-DNS - [x] System DNS auto-configuration — `numa install` / `numa uninstall` - [x] Local service proxy — `.numa` domains with HTTP/HTTPS reverse proxy, auto TLS, WebSocket -- [ ] pkarr integration — resolve Ed25519 keys via Mainline DHT (15M nodes) +- [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes) - [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served -- [ ] Audit protocol — challenge-based verification of resolver honesty -- [ ] Numa Network — proof-of-service consensus, NUMA token, paid `.numa` domains -- [ ] `.onion` bridge — human-readable `.numa` names for Tor hidden services ## License diff --git a/src/ctx.rs b/src/ctx.rs index 18b00ec..cf485bd 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -150,8 +150,17 @@ pub async fn handle_query( ); let mut resp_buffer = BytePacketBuffer::new(); - response.write(&mut resp_buffer)?; - ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; + if response.write(&mut resp_buffer).is_err() { + // Response too large for UDP — set TC bit and send header + question only + debug!("response too large, setting TC bit for {}", qname); + let mut tc_response = DnsPacket::response_from(&query, response.header.rescode); + tc_response.header.truncated_message = true; + let mut tc_buffer = BytePacketBuffer::new(); + tc_response.write(&mut tc_buffer)?; + ctx.socket.send_to(tc_buffer.filled(), src_addr).await?; + } else { + ctx.socket.send_to(resp_buffer.filled(), src_addr).await?; + } // Record stats and query log { diff --git a/src/forward.rs b/src/forward.rs index 63de394..5f8d25f 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -21,7 +21,11 @@ pub async fn forward_query( socket.send_to(send_buffer.filled(), upstream).await?; let mut recv_buffer = BytePacketBuffer::new(); - timeout(timeout_duration, socket.recv_from(&mut recv_buffer.buf)).await??; + let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buffer.buf)).await??; + + if size >= recv_buffer.buf.len() { + log::debug!("upstream response truncated ({} bytes, buffer {})", size, recv_buffer.buf.len()); + } DnsPacket::from_buffer(&mut recv_buffer) } From 2cb87bbe8357d42749fecce191dcb32685a98270 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 21 Mar 2026 03:31:44 +0200 Subject: [PATCH 2/4] fix rustfmt formatting Co-Authored-By: Claude Opus 4.6 --- src/forward.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/forward.rs b/src/forward.rs index 5f8d25f..14ad6f2 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -24,7 +24,11 @@ pub async fn forward_query( let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buffer.buf)).await??; if size >= recv_buffer.buf.len() { - log::debug!("upstream response truncated ({} bytes, buffer {})", size, recv_buffer.buf.len()); + log::debug!( + "upstream response truncated ({} bytes, buffer {})", + size, + recv_buffer.buf.len() + ); } DnsPacket::from_buffer(&mut recv_buffer) From 7a64e7c4aa3e404962204d58a2d5a8cd85b67ea0 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 21 Mar 2026 03:35:21 +0200 Subject: [PATCH 3/4] fix truncation check: use == instead of >= for buffer-full detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recv_from can never return more bytes than the buffer size — the kernel truncates silently. == is the correct heuristic for detecting truncation. Co-Authored-By: Claude Opus 4.6 --- src/forward.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/forward.rs b/src/forward.rs index 14ad6f2..ff5c14f 100644 --- a/src/forward.rs +++ b/src/forward.rs @@ -23,7 +23,7 @@ pub async fn forward_query( let mut recv_buffer = BytePacketBuffer::new(); let (size, _) = timeout(timeout_duration, socket.recv_from(&mut recv_buffer.buf)).await??; - if size >= recv_buffer.buf.len() { + if size == recv_buffer.buf.len() { log::debug!( "upstream response truncated ({} bytes, buffer {})", size, From 5c1f2e013a9e924a6df01496d0642d53957de37c Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 21 Mar 2026 03:51:04 +0200 Subject: [PATCH 4/4] restructure README for Show HN, add post draft Moved "from scratch in Rust" into hero, added AdGuard Home to comparison, named Hagezi Pro blocklist, cut 40% (API table + config to docs), install script first in Quick Start, added Linux mention. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 - README.md | 231 +++++++++++------------------------------------------ 2 files changed, 47 insertions(+), 185 deletions(-) diff --git a/.gitignore b/.gitignore index 45c6531..cfa6940 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,2 @@ /target CLAUDE.md -docs diff --git a/README.md b/README.md index 7ba4813..601adaa 100644 --- a/README.md +++ b/README.md @@ -2,234 +2,97 @@ **DNS you own. Everywhere you go.** -Block ads and trackers. Override DNS for development. Name your local services. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. +A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required. + +Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. ![Numa dashboard](assets/hero-demo.gif) -## Why - -- **Ad blocking that travels with you** — 385K+ domains blocked out of the box. Works on any network: coffee shops, hotels, airports. -- **Developer overrides** — point any hostname to any IP with auto-revert. No more editing `/etc/hosts`. -- **Local service proxy** — access `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. -- **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. -- **Live dashboard** — real-time query stats, blocking controls, override management, local services at `http://numa.numa` (or `localhost:5380`). -- **Single binary, zero config** — just run it. - ## Quick Start -### From source - ```bash -git clone https://github.com/razvandimescu/numa.git -cd numa -cargo build -sudo cargo run # binds to port 53, downloads blocklists on first run -``` +# Install +curl -fsSL https://raw.githubusercontent.com/razvandimescu/numa/main/install.sh | sh -### Docker +# Run (port 53 requires root) +sudo numa -```bash -docker build -t numa . -docker run -p 53:53/udp -p 5380:5380 numa -``` - -### Try it - -Open the dashboard: **http://numa.numa** (or `http://localhost:5380`) - -```bash +# Try it dig @127.0.0.1 google.com # ✓ resolves normally dig @127.0.0.1 ads.google.com # ✗ blocked → 0.0.0.0 ``` -Set Numa as your system DNS (all traffic goes through Numa): -```bash -sudo cargo run -- install # saves current DNS, sets system to 127.0.0.1 -sudo cargo run -- uninstall # restores original DNS settings +Open the dashboard: **http://localhost:5380** -# Or if installed to PATH: -sudo cp target/release/numa /usr/local/bin/ -sudo numa install -sudo numa uninstall +Or build from source: +```bash +git clone https://github.com/razvandimescu/numa.git && cd numa +cargo build --release +sudo ./target/release/numa ``` -Create an override: -```bash -curl -X POST http://localhost:5380/overrides \ - -H 'Content-Type: application/json' \ - -d '{"domain":"api.dev","target":"127.0.0.1","ttl":60,"duration_secs":300}' +## Why Numa -dig @127.0.0.1 api.dev # → 127.0.0.1 (auto-reverts in 5 min) -``` +- **Ad blocking that travels with you** — 385K+ domains blocked via [Hagezi Pro](https://github.com/hagezi/dns-blocklists). Works on any network: coffee shops, hotels, airports. +- **Local service proxy** — `https://frontend.numa` instead of `localhost:5173`. Auto-generated TLS certs, WebSocket support for HMR. Like `/etc/hosts` but with a dashboard and auto-revert. +- **Developer overrides** — point any hostname to any IP, auto-reverts after N minutes. REST API with 22 endpoints. +- **Sub-millisecond caching** — cached lookups in 0ms. Faster than any public resolver. +- **Live dashboard** — real-time stats, query log, blocking controls, service management. +- **macOS + Linux** — `numa install` configures system DNS, `numa service start` runs as launchd/systemd service. ## Local Service Proxy -Name your local dev services with `.numa` domains instead of remembering port numbers: +Name your local dev services with `.numa` domains: ```bash -# Register a service via API -curl -X POST http://localhost:5380/services \ +curl -X POST localhost:5380/services \ -H 'Content-Type: application/json' \ -d '{"name":"frontend","target_port":5173}' -# Now access it by name open http://frontend.numa # → proxied to localhost:5173 ``` -Or configure in `numa.toml`: +- **HTTPS with green lock** — auto-generated local CA + per-service TLS certs +- **WebSocket** — Vite/webpack HMR works through the proxy +- **Health checks** — dashboard shows green/red status per service +- **Persistent** — services survive restarts +- Or configure in `numa.toml`: + ```toml [[services]] name = "frontend" target_port = 5173 - -[[services]] -name = "api" -target_port = 8000 ``` -- `numa.numa` is pre-configured — the dashboard itself, accessible without remembering the port -- **HTTPS with green lock** — auto-generated local CA + per-service TLS certs. `sudo numa install` trusts the CA in your system keychain. -- WebSocket support — Vite/webpack HMR works through the proxy -- Health checks — dashboard shows green/red status for each service -- Services persist across restarts (`~/.config/numa/services.json`) -- Manage via dashboard UI or REST API - -## Resolution Pipeline - -``` -Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream → Respond -``` - -1. **Overrides** — ephemeral, time-scoped redirects (highest priority) -2. **`.numa` TLD** — synthetic domains for local services → returns `127.0.0.1` -3. **Blocklist** — 385K+ ad/tracker domains → returns `0.0.0.0` / `::` -4. **Local zones** — records defined in `[[zones]]` config -5. **Cache** — TTL-adjusted cached upstream responses (sub-ms) -6. **Forward** — query upstream resolver, cache the result -7. **SERVFAIL** — returned on upstream failure - -## Dashboard - -Live at `http://localhost:5380` when Numa is running: - -- Total queries, cache hit rate, blocked count, uptime -- Resolution path breakdown (forward / cached / local / override / blocked) -- Scrolling query log with colored path tags -- Active overrides with create/edit/delete -- Local services with health status and add/remove -- Blocking controls: toggle on/off, pause 5 minutes, one-click allowlist -- Cached domains list - -## Configuration - -`numa.toml` (all sections optional, sensible defaults if missing): - -```toml -[server] -bind_addr = "0.0.0.0:53" -api_port = 5380 - -[upstream] -address = "8.8.8.8" -port = 53 -timeout_ms = 3000 - -[cache] -max_entries = 10000 -min_ttl = 60 -max_ttl = 86400 - -[blocking] -enabled = true -lists = [ - "https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt", -] -refresh_hours = 24 -allowlist = [] - -[proxy] -enabled = true -port = 80 -tld = "numa" - -[[services]] -name = "frontend" -target_port = 5173 - -[[zones]] -domain = "mysite.local" -record_type = "A" -value = "127.0.0.1" -ttl = 60 -``` - -## HTTP API - -REST API on port 5380 (22 endpoints): - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/` | GET | Live dashboard | -| `/overrides` | POST | Create override(s) | -| `/overrides` | GET | List active overrides | -| `/overrides` | DELETE | Clear all overrides | -| `/overrides/environment` | POST | Batch load overrides | -| `/overrides/{domain}` | GET | Get specific override | -| `/overrides/{domain}` | DELETE | Remove specific override | -| `/services` | GET | List local services (with health status) | -| `/services` | POST | Register a local service | -| `/services/{name}` | DELETE | Remove a local service | -| `/blocking/stats` | GET | Blocklist stats (domains loaded, sources, enabled) | -| `/blocking/toggle` | PUT | Enable/disable blocking | -| `/blocking/pause` | POST | Pause blocking for N minutes | -| `/blocking/allowlist` | GET | List allowlisted domains | -| `/blocking/allowlist` | POST | Add domain to allowlist | -| `/blocking/allowlist/{domain}` | DELETE | Remove from allowlist | -| `/blocking/check/{domain}` | GET | Check if domain is blocked | -| `/diagnose/{domain}` | GET | Trace resolution path | -| `/query-log` | GET | Recent queries (filterable) | -| `/stats` | GET | Server statistics | -| `/cache` | GET | List cached entries | -| `/cache` | DELETE | Flush cache | -| `/cache/{domain}` | DELETE | Flush specific domain | -| `/health` | GET | Health check | - ## How It Compares -| | Pi-hole | NextDNS | Cloudflare | Numa | -|---|---|---|---|---| -| Ad blocking | Yes | Yes | Limited | 385K+ domains | -| Portable | No (Raspberry Pi) | Cloud only | Cloud only | Single binary | -| Developer overrides | No | No | No | REST API + auto-expiry | -| Local service proxy | No | No | No | `.numa` domains + HTTPS + WebSocket | -| Data stays local | Yes | Cloud | Cloud | 100% local | -| Zero config | Complex setup | Yes | Yes | Works out of the box | -| Self-sovereign DNS | No | No | No | pkarr/DHT roadmap | +| | Pi-hole | AdGuard Home | NextDNS | Cloudflare | Numa | +|---|---|---|---|---|---| +| Ad blocking | Yes | Yes | Yes | Limited | 385K+ domains | +| Portable (travels with laptop) | No (appliance) | No (appliance) | Cloud only | Cloud only | Single binary | +| Developer overrides | No | No | No | No | REST API + auto-expiry | +| Local service proxy | No | No | No | No | `.numa` + HTTPS + WS | +| Data stays local | Yes | Yes | Cloud | Cloud | 100% local | +| Zero config | Complex | Docker/setup | Yes | Yes | Works out of the box | +| Self-sovereign DNS | No | No | No | No | pkarr/DHT roadmap | -## Use Cases +## How It Works -**Block ads everywhere** — Run Numa on your laptop. Your ad blocker works on any network. +``` +Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream +``` -**Name your local services** — `frontend.numa` instead of `localhost:5173`. CORS-friendly, HMR-compatible. +No DNS libraries. The wire protocol — headers, labels, compression pointers, record types — is parsed and serialized by hand. Runs on `tokio` + `axum`, async per-query task spawning. -**Mock external services** — `Point api.stripe.com to localhost:8080 for 30 minutes` - -**Provision dev environments** — Create overrides for `db.dev`, `api.dev`, `cache.dev` - -**Debug DNS** — `/diagnose/example.com` traces the full resolution path - -## Built From Scratch - -Zero external DNS libraries. RFC 1035 wire protocol parsed by hand. Dependencies: `tokio`, `axum`, `serde`, `toml`, `reqwest` (for blocklist downloads). +[Full API reference (22 endpoints)](docs/development-plan.md) · [Configuration reference](numa.toml) ## Roadmap - [x] DNS proxy core — forwarding, caching, local zones - [x] Developer overrides — REST API with auto-expiry -- [x] Ad blocking — 385K+ domains, dashboard, allowlist -- [x] System DNS auto-discovery — Tailscale, VPN split-DNS -- [x] System DNS auto-configuration — `numa install` / `numa uninstall` -- [x] Local service proxy — `.numa` domains with HTTP/HTTPS reverse proxy, auto TLS, WebSocket +- [x] Ad blocking — 385K+ domains, live dashboard, allowlist +- [x] System integration — macOS + Linux, launchd/systemd, Tailscale/VPN auto-discovery +- [x] Local service proxy — `.numa` domains, HTTP/HTTPS proxy, auto TLS, WebSocket - [ ] pkarr integration — self-sovereign DNS via Mainline DHT (15M nodes) - [ ] Global `.numa` names — self-publish, DHT-backed, first-come-first-served