From 8f959ce0a563da8f7f1f64edc93c7c2480419b4d Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Fri, 20 Mar 2026 15:07:15 +0200 Subject: [PATCH] add local service proxy with .numa domains HTTP reverse proxy on port 80 lets developers use clean domain names (frontend.numa, api.numa) instead of localhost:PORT. Includes WebSocket upgrade support for HMR, TCP health checks, dashboard UI panel, and REST API for service management. numa.numa is preconfigured for the dashboard itself. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 27 ++++--- Cargo.lock | 59 ++++++++++++++ Cargo.toml | 4 + README.md | 70 +++++++++++++--- numa.toml | 14 ++++ site/dashboard.html | 112 +++++++++++++++++++++++++- site/index.html | 56 +++++++++---- src/api.rs | 107 ++++++++++++++++++++++++ src/config.rs | 50 ++++++++++-- src/ctx.rs | 25 +++++- src/lib.rs | 2 + src/main.rs | 35 +++++++- src/proxy.rs | 188 +++++++++++++++++++++++++++++++++++++++++++ src/service_store.rs | 52 ++++++++++++ src/system_dns.rs | 14 +++- 15 files changed, 762 insertions(+), 53 deletions(-) create mode 100644 src/proxy.rs create mode 100644 src/service_store.rs diff --git a/CLAUDE.md b/CLAUDE.md index 53aad5e..e1739e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ numa service stop # uninstall service + restore DNS numa service status # check service status ``` -Dashboard: http://localhost:5380 +Dashboard: http://numa.numa (or http://localhost:5380) ## Architecture @@ -40,23 +40,27 @@ Dashboard: http://localhost:5380 UDP :53 ──▶ handle_query() │ ├─ 1. Override Store (ephemeral, auto-expiry) - ├─ 2. Blocklist (385K+ domains, subdomain matching) - ├─ 3. Local Zones (TOML config) - ├─ 4. Cache (TTL-aware, lazy eviction) - └─ 5. Upstream Forward (auto-detected from OS, conditional forwarding) + ├─ 2. .numa TLD (local service domains → 127.0.0.1) + ├─ 3. Blocklist (385K+ domains, subdomain matching) + ├─ 4. Local Zones (TOML config) + ├─ 5. Cache (TTL-aware, lazy eviction) + └─ 6. Upstream Forward (auto-detected from OS, conditional forwarding) -HTTP :5380 ──▶ Axum REST API (19 endpoints) + Dashboard +HTTP :80 ──▶ Reverse proxy for .numa domains (WebSocket support) +HTTP :5380 ──▶ Axum REST API (22 endpoints) + Dashboard ``` ### Source Files ``` src/ - main.rs # startup: load config, bind UDP, spawn API, blocklist download, per-query task loop + main.rs # startup: load config, bind UDP, spawn API + proxy, blocklist download, per-query task loop lib.rs # module declarations, Error/Result type aliases ctx.rs # ServerCtx shared state + handle_query() pipeline - api.rs # Axum REST server (19 endpoints, port 5380) + embedded dashboard - config.rs # TOML config loading with defaults (server, upstream, cache, blocking, zones) + api.rs # Axum REST server (22 endpoints, port 5380) + embedded dashboard + config.rs # TOML config loading with defaults (server, upstream, cache, blocking, proxy, zones) + proxy.rs # HTTP reverse proxy for .numa domains (port 80, WebSocket upgrade support) + service_store.rs # ServiceStore — name-to-port mappings for local service proxy blocklist.rs # BlocklistStore — HashSet, download, parse, subdomain matching, check override_store.rs # OverrideStore — ephemeral domain overrides with auto-expiry query_log.rs # ring buffer (VecDeque, 1000 entries) for recent queries @@ -76,12 +80,13 @@ site/ ## Config -`numa.toml` at project root. Sections: `[server]`, `[upstream]`, `[cache]`, `[blocking]`, `[[zones]]`. Falls back to sensible defaults if file is missing. Upstream auto-detected from system resolver if not set. +`numa.toml` at project root. Sections: `[server]`, `[upstream]`, `[cache]`, `[blocking]`, `[proxy]`, `[[services]]`, `[[zones]]`. Falls back to sensible defaults if file is missing. Upstream auto-detected from system resolver if not set. ## REST API Dashboard: GET `/` (embedded HTML) Override management: POST/GET/DELETE `/overrides`, POST `/overrides/environment` +Services: GET/POST `/services`, DELETE `/services/{name}` Blocking: GET `/blocking/stats`, PUT `/blocking/toggle`, POST `/blocking/pause`, GET/POST `/blocking/allowlist`, GET `/blocking/check/{domain}` Diagnostics: GET `/diagnose/{domain}`, `/query-log`, `/stats`, `/cache`, `/health` Cache: DELETE `/cache`, `/cache/{domain}` @@ -89,7 +94,7 @@ Cache: DELETE `/cache`, `/cache/{domain}` ## Key Details - Rust 2021 edition, async via `tokio` (rt-multi-thread) -- Deps: tokio, axum, serde, serde_json, toml, log, env_logger, reqwest (zero DNS libraries) +- Deps: tokio, axum, hyper, hyper-util, serde, serde_json, toml, log, env_logger, reqwest, futures (zero DNS libraries) - DNS buffer size: 4096 bytes (EDNS-compatible). UNKNOWN record types (e.g. OPT) filtered on serialization. - `BytePacketBuffer::read_qname` handles label compression (pointer jumps) - `type Error = Box` / `type Result` aliased in `lib.rs` diff --git a/Cargo.lock b/Cargo.lock index b8a07dd..e09573e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -226,6 +226,21 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -233,6 +248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -241,6 +257,40 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + [[package]] name = "futures-task" version = "0.3.32" @@ -253,8 +303,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -632,6 +687,10 @@ version = "0.1.0" dependencies = [ "axum", "env_logger", + "futures", + "http-body-util", + "hyper", + "hyper-util", "log", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index 32079a0..2f824f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,3 +18,7 @@ toml = "0.8" log = "0.4" env_logger = "0.11" reqwest = { version = "0.12", features = ["rustls-tls"], default-features = false } +hyper = { version = "1", features = ["client", "http1"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } +http-body-util = "0.1" +futures = "0.3" diff --git a/README.md b/README.md index f5cb828..af4a118 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,15 @@ **DNS you own. Everywhere you go.** -Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. +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. ## 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 `frontend.numa`, `api.numa` instead of `localhost:5173`. Clean URLs with 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 at `http://localhost:5380`. +- **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 @@ -32,7 +33,7 @@ docker run -p 53:53/udp -p 5380:5380 numa ### Try it -Open the dashboard: **http://localhost:5380** +Open the dashboard: **http://numa.numa** (or `http://localhost:5380`) ```bash dig @127.0.0.1 google.com # ✓ resolves normally @@ -59,18 +60,49 @@ curl -X POST http://localhost:5380/overrides \ dig @127.0.0.1 api.dev # → 127.0.0.1 (auto-reverts in 5 min) ``` +## Local Service Proxy + +Name your local dev services with `.numa` domains instead of remembering port numbers: + +```bash +# Register a service via API +curl -X POST http://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`: +```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 +- WebSocket support — Vite/webpack HMR works through the proxy +- Health checks — dashboard shows green/red status for each service +- Manage via dashboard UI or REST API + ## Resolution Pipeline ``` -Query → Overrides → Blocklist → Local Zones → Cache → Upstream → Respond +Query → Overrides → .numa TLD → Blocklist → Local Zones → Cache → Upstream → Respond ``` 1. **Overrides** — ephemeral, time-scoped redirects (highest priority) -2. **Blocklist** — 385K+ ad/tracker domains → returns `0.0.0.0` / `::` -3. **Local zones** — records defined in `[[zones]]` config -4. **Cache** — TTL-adjusted cached upstream responses (sub-ms) -5. **Forward** — query upstream resolver, cache the result -6. **SERVFAIL** — returned on upstream failure +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 @@ -80,6 +112,7 @@ Live at `http://localhost:5380` when Numa is running: - 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 @@ -110,6 +143,15 @@ lists = [ refresh_hours = 24 allowlist = [] +[proxy] +enabled = true +port = 80 +tld = "numa" + +[[services]] +name = "frontend" +target_port = 5173 + [[zones]] domain = "mysite.local" record_type = "A" @@ -119,7 +161,7 @@ ttl = 60 ## HTTP API -REST API on port 5380 (18 endpoints): +REST API on port 5380 (22 endpoints): | Endpoint | Method | Description | |----------|--------|-------------| @@ -130,12 +172,16 @@ REST API on port 5380 (18 endpoints): | `/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 | @@ -151,6 +197,7 @@ REST API on port 5380 (18 endpoints): | 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 + 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 | @@ -159,6 +206,8 @@ REST API on port 5380 (18 endpoints): **Block ads everywhere** — Run Numa on your laptop. Your ad blocker works on any network. +**Name your local services** — `frontend.numa` instead of `localhost:5173`. CORS-friendly, HMR-compatible. + **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` @@ -176,6 +225,7 @@ Zero external DNS libraries. RFC 1035 wire protocol parsed by hand. Dependencies - [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 reverse proxy + WebSocket support - [ ] pkarr integration — self-sovereign DNS via Mainline DHT - [ ] Decentralized resolver network — staking, auditing, token economics diff --git a/numa.toml b/numa.toml index b2aee0e..18bb59d 100644 --- a/numa.toml +++ b/numa.toml @@ -13,6 +13,20 @@ max_entries = 10000 min_ttl = 60 max_ttl = 86400 +[proxy] +enabled = true +port = 80 +tld = "numa" + +# Pre-configured services (numa.numa is always added automatically) +# [[services]] +# name = "frontend" +# target_port = 5173 +# +# [[services]] +# name = "api" +# target_port = 8000 + # Example zone records: # [[zones]] # domain = "dimescu.ro" diff --git a/site/dashboard.html b/site/dashboard.html index b1756bf..fde4c35 100644 --- a/site/dashboard.html +++ b/site/dashboard.html @@ -348,6 +348,41 @@ body { flex-shrink: 0; } +/* Service items */ +.service-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); +} +.service-item:last-child { border-bottom: none; } +.service-info { flex: 1; min-width: 0; } +.service-name { + font-family: var(--font-mono); + font-size: 0.8rem; + font-weight: 500; + color: var(--cyan); +} +.service-name a { + color: inherit; + text-decoration: none; +} +.service-name a:hover { text-decoration: underline; } +.service-port { + font-family: var(--font-mono); + font-size: 0.68rem; + color: var(--text-dim); +} +.health-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} +.health-dot.up { background: var(--emerald); } +.health-dot.down { background: var(--rose); } + /* Override form */ .override-form { display: flex; @@ -571,6 +606,26 @@ body { + +
+
+ Local Services +
+
+
+
+ + +
+ +
+
+
+
No services configured
+
+
+
+
@@ -781,11 +836,12 @@ function renderCache(entries) { async function refresh() { try { - const [stats, logs, overrides, cache] = await Promise.all([ + const [stats, logs, overrides, cache, services] = await Promise.all([ fetchJSON('/stats'), fetchJSON('/query-log?limit=100'), fetchJSON('/overrides'), fetchJSON('/cache'), + fetchJSON('/services'), ]); // Connection status @@ -843,6 +899,7 @@ async function refresh() { renderQueryLog(logs); renderOverrides(overrides); renderCache(cache); + renderServices(services); } catch (err) { document.getElementById('statusDot').className = 'status-dot error'; @@ -914,6 +971,59 @@ async function checkDomain(event) { return false; } +function renderServices(entries) { + const el = document.getElementById('servicesList'); + if (!entries.length) { + el.innerHTML = '
No services configured
'; + return; + } + el.innerHTML = entries.map(e => ` +
+ +
+ +
:${e.target_port}
+
+ ${e.name === 'numa' ? '' : ``} +
+ `).join(''); +} + +async function addService(event) { + event.preventDefault(); + const errEl = document.getElementById('serviceError'); + errEl.style.display = 'none'; + try { + const body = { + name: document.getElementById('svcName').value.trim(), + target_port: parseInt(document.getElementById('svcPort').value) || 0, + }; + const res = await fetch(API + '/services', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text); + } + document.getElementById('svcName').value = ''; + document.getElementById('svcPort').value = ''; + refresh(); + } catch (err) { + errEl.textContent = err.message; + errEl.style.display = 'block'; + } + return false; +} + +async function deleteService(name) { + try { + await fetch(API + '/services/' + encodeURIComponent(name), { method: 'DELETE' }); + refresh(); + } catch (err) { /* next refresh will update */ } +} + // Initial load + polling refresh(); setInterval(refresh, 2000); diff --git a/site/index.html b/site/index.html index 2e2c6b5..3fcab04 100644 --- a/site/index.html +++ b/site/index.html @@ -4,7 +4,7 @@ Numa — DNS that governs itself - + @@ -612,6 +612,12 @@ p.lead { color: var(--emerald); } .pipeline-box.hl-emerald:hover { border-color: var(--emerald); color: var(--emerald); } +.pipeline-box.hl-cyan { + border-color: rgba(74, 124, 138, 0.35); + background: rgba(74, 124, 138, 0.06); + color: var(--cyan); +} +.pipeline-box.hl-cyan:hover { border-color: var(--cyan); color: var(--cyan); } .pipeline-arrow { font-family: var(--font-mono); @@ -1004,7 +1010,7 @@ footer .closing {
DNS you own. Everywhere you go.

After Numa Pompilius, who built institutions that outlasted kings.

- Block ads and trackers. Override DNS for development. Cache for speed. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account. Your DNS travels with you. + Block ads and trackers. Override DNS for development. Name your local services with .numa domains. A single portable binary built from scratch in Rust — no Raspberry Pi, no cloud, no account.

Get Started @@ -1066,8 +1072,9 @@ footer .closing {
  • Ad & tracker blocking — 385K+ domains, zero config
  • Ephemeral DNS overrides with auto-revert
  • +
  • Local service proxy — frontend.numa instead of localhost:5173
  • Live dashboard with real-time stats and controls
  • -
  • REST API — 18 endpoints for programmatic control
  • +
  • REST API — 22 endpoints for programmatic control
  • TTL-aware caching (sub-ms lookups)
  • Single binary, portable — your ad blocker travels with you
@@ -1116,6 +1123,10 @@ footer .closing {
Overrides
+
.numa TLD
+ +
Blocklist
+
Local Zones
Cache
@@ -1230,6 +1241,14 @@ footer .closing { No REST API + auto-expiry + + Local service proxy + No + No + No + No + .numa domains + WebSocket + Data stays local Yes @@ -1286,10 +1305,10 @@ footer .closing {
Zero — wire protocol parsed from scratch
Dependencies
-
6 runtime crates (tokio, axum, serde, serde_json, toml, log)
+
8 runtime crates (tokio, axum, hyper, serde, serde_json, toml, log, futures)
Packet Format
-
RFC 1035 compliant, 512-byte UDP
+
RFC 1035 compliant, 4096-byte UDP (EDNS)
Concurrency
Arc<ServerCtx> + std::sync::Mutex (sub-µs holds, never across .await)
@@ -1299,13 +1318,12 @@ footer .closing {
$ cargo install numa -$ sudo numa # bind to :53 +$ sudo numa # bind to :53, :80, :5380 $ dig @127.0.0.1 google.com # test resolution -$ curl localhost:5380/overrides # REST API -$ curl -X POST localhost:5380/overrides \ - -d '{"domain":"api.stripe.com", - "target":"127.0.0.1", - "duration_secs":1800}' # 30-min override +$ open http://numa.numa # dashboard +$ curl -X POST localhost:5380/services \ + -d '{"name":"frontend", + "target_port":5173}' # http://frontend.numa
@@ -1333,28 +1351,32 @@ footer .closing { Phase 2 Ad & tracker blocking — 385K+ domains, live dashboard, one-click allowlist
-
+
Phase 3 System integration — auto-discovery of OS DNS routing, one-command install
-
+
Phase 4 + Local service proxy — .numa domains, HTTP reverse proxy, WebSocket support +
+
+ Phase 5 pkarr spike — DHT resolution and publish endpoint
- Phase 5 + Phase 6 pkarr product — human-readable aliases, re-publish daemon, key management
- Phase 4 + Phase 7 Challenge and audit protocol for verifiable resolver behavior
- Phase 5 + Phase 8 Token economics, staking, and slashing mechanism
- Phase 6 + Phase 9 Decentralized resolver marketplace
diff --git a/src/api.rs b/src/api.rs index be919bd..467041b 100644 --- a/src/api.rs +++ b/src/api.rs @@ -42,6 +42,9 @@ pub fn router(ctx: Arc) -> Router { "/blocking/allowlist/{domain}", delete(blocking_allowlist_remove), ) + .route("/services", get(list_services)) + .route("/services", post(create_service)) + .route("/services/{name}", delete(remove_service)) .with_state(ctx) } @@ -572,3 +575,107 @@ async fn blocking_allowlist_remove( StatusCode::NOT_FOUND } } + +// --- Service proxy handlers --- + +#[derive(Serialize)] +struct ServiceResponse { + name: String, + target_port: u16, + url: String, + healthy: bool, +} + +#[derive(Deserialize)] +struct CreateServiceRequest { + name: String, + target_port: u16, +} + +async fn list_services(State(ctx): State>) -> Json> { + let entries: Vec<_> = { + let store = ctx.services.lock().unwrap(); + store + .list() + .into_iter() + .map(|e| (e.name.clone(), e.target_port)) + .collect() + }; + let tld = &ctx.proxy_tld; + + // Run all health checks concurrently + let health_futures: Vec<_> = entries.iter().map(|(_, port)| check_health(*port)).collect(); + let health_results = futures::future::join_all(health_futures).await; + + let results: Vec<_> = entries + .into_iter() + .zip(health_results) + .map(|((name, port), healthy)| ServiceResponse { + url: format!("http://{}.{}", name, tld), + name, + target_port: port, + healthy, + }) + .collect(); + Json(results) +} + +async fn create_service( + State(ctx): State>, + Json(req): Json, +) -> Result<(StatusCode, Json), (StatusCode, String)> { + let name = req.name.to_lowercase(); + + // Validate name: alphanumeric + hyphens only, 1-63 chars + if name.is_empty() || name.len() > 63 { + return Err((StatusCode::BAD_REQUEST, "name must be 1-63 characters".into())); + } + if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return Err(( + StatusCode::BAD_REQUEST, + "name must contain only alphanumeric characters and hyphens".into(), + )); + } + if req.target_port == 0 { + return Err((StatusCode::BAD_REQUEST, "target_port must be > 0".into())); + } + + let tld = &ctx.proxy_tld; + ctx.services.lock().unwrap().insert(&name, req.target_port); + + let healthy = check_health(req.target_port).await; + Ok(( + StatusCode::CREATED, + Json(ServiceResponse { + url: format!("http://{}.{}", name, tld), + name, + target_port: req.target_port, + healthy, + }), + )) +} + +async fn remove_service( + State(ctx): State>, + Path(name): Path, +) -> StatusCode { + if name.eq_ignore_ascii_case("numa") { + return StatusCode::FORBIDDEN; + } + let mut store = ctx.services.lock().unwrap(); + if store.remove(&name) { + StatusCode::NO_CONTENT + } else { + StatusCode::NOT_FOUND + } +} + +async fn check_health(port: u16) -> bool { + tokio::time::timeout( + std::time::Duration::from_millis(100), + tokio::net::TcpStream::connect(format!("127.0.0.1:{}", port)), + ) + .await + .map(|r| r.is_ok()) + .unwrap_or(false) +} diff --git a/src/config.rs b/src/config.rs index 799bb41..222ac6f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,7 +9,7 @@ use crate::question::QueryType; use crate::record::DnsRecord; use crate::Result; -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct Config { #[serde(default)] pub server: ServerConfig, @@ -21,6 +21,10 @@ pub struct Config { pub blocking: BlockingConfig, #[serde(default)] pub zones: Vec, + #[serde(default)] + pub proxy: ProxyConfig, + #[serde(default)] + pub services: Vec, } #[derive(Deserialize)] @@ -156,15 +160,45 @@ fn default_zone_ttl() -> u32 { 300 } +#[derive(Deserialize, Clone)] +pub struct ProxyConfig { + #[serde(default = "default_proxy_enabled")] + pub enabled: bool, + #[serde(default = "default_proxy_port")] + pub port: u16, + #[serde(default = "default_proxy_tld")] + pub tld: String, +} + +impl Default for ProxyConfig { + fn default() -> Self { + ProxyConfig { + enabled: default_proxy_enabled(), + port: default_proxy_port(), + tld: default_proxy_tld(), + } + } +} + +fn default_proxy_enabled() -> bool { + true +} +fn default_proxy_port() -> u16 { + 80 +} +fn default_proxy_tld() -> String { + "numa".to_string() +} + +#[derive(Deserialize, Clone)] +pub struct ServiceConfig { + pub name: String, + pub target_port: u16, +} + pub fn load_config(path: &str) -> Result { if !Path::new(path).exists() { - return Ok(Config { - server: ServerConfig::default(), - upstream: UpstreamConfig::default(), - cache: CacheConfig::default(), - blocking: BlockingConfig::default(), - zones: Vec::new(), - }); + return Ok(Config::default()); } let contents = std::fs::read_to_string(path)?; let config: Config = toml::from_str(&contents)?; diff --git a/src/ctx.rs b/src/ctx.rs index a407f83..18b00ec 100644 --- a/src/ctx.rs +++ b/src/ctx.rs @@ -14,7 +14,9 @@ use crate::header::ResultCode; use crate::override_store::OverrideStore; use crate::packet::DnsPacket; use crate::query_log::{QueryLog, QueryLogEntry}; +use crate::question::QueryType; use crate::record::DnsRecord; +use crate::service_store::ServiceStore; use crate::stats::{QueryPath, ServerStats}; use crate::system_dns::ForwardingRule; @@ -26,9 +28,12 @@ pub struct ServerCtx { pub overrides: Mutex, pub blocklist: Mutex, pub query_log: Mutex, + pub services: Mutex, pub forwarding_rules: Vec, pub upstream: SocketAddr, pub timeout: Duration, + pub proxy_tld: String, + pub proxy_tld_suffix: String, // pre-computed ".{tld}" to avoid per-query allocation } pub async fn handle_query( @@ -51,7 +56,7 @@ pub async fn handle_query( None => return Ok(()), }; - // Pipeline: overrides -> blocklist -> local zones -> cache -> upstream + // Pipeline: overrides -> .tld interception -> blocklist -> local zones -> cache -> upstream // Each lock is scoped to avoid holding MutexGuard across await points. let (response, path) = { let override_record = ctx.overrides.lock().unwrap().lookup(&qname); @@ -59,8 +64,24 @@ pub async fn handle_query( let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); resp.answers.push(record); (resp, QueryPath::Overridden) + } else if !ctx.proxy_tld_suffix.is_empty() + && (qname.ends_with(&ctx.proxy_tld_suffix) || qname == ctx.proxy_tld) + { + let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); + match qtype { + QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { + domain: qname.clone(), + addr: std::net::Ipv6Addr::LOCALHOST, + ttl: 300, + }), + _ => resp.answers.push(DnsRecord::A { + domain: qname.clone(), + addr: std::net::Ipv4Addr::LOCALHOST, + ttl: 300, + }), + } + (resp, QueryPath::Local) } else if ctx.blocklist.lock().unwrap().is_blocked(&qname) { - use crate::question::QueryType; let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR); match qtype { QueryType::AAAA => resp.answers.push(DnsRecord::AAAA { diff --git a/src/lib.rs b/src/lib.rs index 60a175d..356e12b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -8,9 +8,11 @@ pub mod forward; pub mod header; pub mod override_store; pub mod packet; +pub mod proxy; pub mod query_log; pub mod question; pub mod record; +pub mod service_store; pub mod stats; pub mod system_dns; diff --git a/src/main.rs b/src/main.rs index ef85cbb..b3f87e0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ use numa::config::{build_zone_map, load_config}; use numa::ctx::{handle_query, ServerCtx}; use numa::override_store::OverrideStore; use numa::query_log::QueryLog; +use numa::service_store::ServiceStore; use numa::stats::ServerStats; use numa::system_dns::{ discover_system_dns, install_service, install_system_dns, restart_service, service_status, @@ -49,6 +50,10 @@ async fn main() -> numa::Result<()> { } }; } + "version" | "--version" | "-V" => { + eprintln!("numa {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } "help" | "--help" | "-h" => { eprintln!("Usage: numa [command] [config-path]"); eprintln!(); @@ -99,6 +104,13 @@ async fn main() -> numa::Result<()> { blocklist.set_enabled(false); } + // Build service store from config, always include numa dashboard + let mut service_store = ServiceStore::new(); + service_store.insert("numa", config.server.api_port); + for svc in &config.services { + service_store.insert(&svc.name, svc.target_port); + } + let forwarding_rules = system_dns.forwarding_rules; let ctx = Arc::new(ServerCtx { @@ -113,14 +125,21 @@ async fn main() -> numa::Result<()> { overrides: Mutex::new(OverrideStore::new()), blocklist: Mutex::new(blocklist), query_log: Mutex::new(QueryLog::new(1000)), + services: Mutex::new(service_store), forwarding_rules, upstream, timeout: Duration::from_millis(config.upstream.timeout_ms), + proxy_tld_suffix: if config.proxy.tld.is_empty() { + String::new() + } else { + format!(".{}", config.proxy.tld) + }, + proxy_tld: config.proxy.tld.clone(), }); let zone_count: usize = ctx.zone_map.values().map(|m| m.len()).sum(); eprintln!("\n\x1b[38;2;192;98;58m ╔══════════════════════════════════════════╗\x1b[0m"); - eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m"); + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[1;38;2;192;98;58mNUMA\x1b[0m \x1b[3;38;2;163;152;136mDNS that governs itself\x1b[0m \x1b[38;2;163;152;136mv{}\x1b[0m \x1b[38;2;192;98;58m║\x1b[0m", env!("CARGO_PKG_VERSION")); eprintln!("\x1b[38;2;192;98;58m ╠══════════════════════════════════════════╣\x1b[0m"); eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mDNS\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", config.server.bind_addr); eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mAPI\x1b[0m http://localhost:{:<16}\x1b[38;2;192;98;58m║\x1b[0m", api_port); @@ -130,6 +149,10 @@ async fn main() -> numa::Result<()> { eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mCache\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("max {} entries", config.cache.max_entries)); eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mBlocking\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", if config.blocking.enabled { format!("{} lists", config.blocking.lists.len()) } else { "disabled".to_string() }); + if config.proxy.enabled { + eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mProxy\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", + format!("http://*.{} on :{}", config.proxy.tld, config.proxy.port)); + } if !ctx.forwarding_rules.is_empty() { eprintln!("\x1b[38;2;192;98;58m ║\x1b[0m \x1b[38;2;107;124;78mRouting\x1b[0m {:<30}\x1b[38;2;192;98;58m║\x1b[0m", format!("{} conditional rules", ctx.forwarding_rules.len())); @@ -171,6 +194,16 @@ async fn main() -> numa::Result<()> { axum::serve(listener, app).await.unwrap(); }); + // Spawn HTTP reverse proxy for .numa domains + if config.proxy.enabled { + let proxy_ctx = Arc::clone(&ctx); + let proxy_port = config.proxy.port; + let proxy_tld = config.proxy.tld.clone(); + tokio::spawn(async move { + numa::proxy::start_proxy(proxy_ctx, proxy_port, &proxy_tld).await; + }); + } + // UDP DNS listener #[allow(clippy::infinite_loop)] loop { diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..53a7b37 --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,188 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{Request, State}; +use axum::response::IntoResponse; +use axum::routing::any; +use axum::Router; +use http_body_util::BodyExt; +use hyper::StatusCode; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use log::{debug, error, info, warn}; +use tokio::io::copy_bidirectional; + +use crate::ctx::ServerCtx; + +type HttpClient = Client; + +#[derive(Clone)] +struct ProxyState { + ctx: Arc, + client: HttpClient, + tld_suffix: String, // pre-computed ".{tld}" +} + +pub async fn start_proxy(ctx: Arc, port: u16, tld: &str) { + let addr: SocketAddr = ([0, 0, 0, 0], port).into(); + let listener = match tokio::net::TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + warn!( + "proxy: could not bind port {} ({}) — proxy disabled", + port, e + ); + return; + } + }; + info!("HTTP proxy listening on {}", addr); + + let client: HttpClient = Client::builder(TokioExecutor::new()) + .http1_preserve_header_case(true) + .build_http(); + + let state = ProxyState { + ctx, + client, + tld_suffix: format!(".{}", tld), + }; + + let app = Router::new() + .fallback(any(proxy_handler)) + .with_state(state); + + axum::serve(listener, app).await.unwrap(); +} + +fn extract_host(req: &Request) -> Option { + req.headers() + .get(hyper::header::HOST) + .and_then(|v| v.to_str().ok()) + .map(|h| h.split(':').next().unwrap_or(h).to_lowercase()) +} + +async fn proxy_handler( + State(state): State, + req: Request, +) -> axum::response::Response { + let hostname = match extract_host(&req) { + Some(h) => h, + None => { + return (StatusCode::BAD_REQUEST, "missing Host header").into_response(); + } + }; + + let service_name = match hostname.strip_suffix(state.tld_suffix.as_str()) { + Some(name) => name.to_string(), + None => { + return ( + StatusCode::BAD_GATEWAY, + format!("not a {} domain: {}", state.tld_suffix, hostname), + ) + .into_response() + } + }; + + let target_port = { + let store = state.ctx.services.lock().unwrap(); + match store.lookup(&service_name) { + Some(entry) => entry.target_port, + None => { + return ( + StatusCode::BAD_GATEWAY, + format!("unknown service: {}{}", service_name, state.tld_suffix), + ) + .into_response() + } + } + }; + + let path_and_query = req + .uri() + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or("/"); + let target_uri: hyper::Uri = format!("http://127.0.0.1:{}{}", target_port, path_and_query) + .parse() + .unwrap(); + + // Check for upgrade request (WebSocket, etc.) + let is_upgrade = req.headers().get(hyper::header::UPGRADE).is_some(); + + if is_upgrade { + return handle_upgrade(req, target_uri, state.client.clone()).await; + } + + // Regular HTTP proxy + let (mut parts, body) = req.into_parts(); + parts.uri = target_uri; + let proxied_req = Request::from_parts(parts, body); + + match state.client.request(proxied_req).await { + Ok(resp) => { + let (parts, body) = resp.into_parts(); + let body = Body::new(body.map_err(axum::Error::new)); + axum::response::Response::from_parts(parts, body) + } + Err(e) => (StatusCode::BAD_GATEWAY, format!("proxy error: {}", e)).into_response(), + } +} + +async fn handle_upgrade( + mut req: Request, + target_uri: hyper::Uri, + client: HttpClient, +) -> axum::response::Response { + // Save the client-side upgrade future before forwarding + let client_upgrade = hyper::upgrade::on(&mut req); + + // Forward the request to backend + let (mut parts, body) = req.into_parts(); + parts.uri = target_uri; + let backend_req = Request::from_parts(parts, body); + + let mut backend_resp = match client.request(backend_req).await { + Ok(r) => r, + Err(e) => { + return (StatusCode::BAD_GATEWAY, format!("upgrade error: {}", e)).into_response() + } + }; + + if backend_resp.status() != StatusCode::SWITCHING_PROTOCOLS { + let (parts, body) = backend_resp.into_parts(); + let body = Body::new(body.map_err(axum::Error::new)); + return axum::response::Response::from_parts(parts, body); + } + + // Save response headers before consuming for upgrade + let resp_headers = backend_resp.headers().clone(); + let backend_upgrade = hyper::upgrade::on(&mut backend_resp); + + // Spawn bidirectional pipe once both sides are upgraded + tokio::spawn(async move { + let (client_io, backend_io) = match tokio::try_join!(client_upgrade, backend_upgrade) { + Ok((c, b)) => (c, b), + Err(e) => { + error!("proxy upgrade failed: {}", e); + return; + } + }; + + let mut client_rw = hyper_util::rt::TokioIo::new(client_io); + let mut backend_rw = hyper_util::rt::TokioIo::new(backend_io); + + match copy_bidirectional(&mut client_rw, &mut backend_rw).await { + Ok((up, down)) => debug!("ws proxy closed: {} up, {} down bytes", up, down), + Err(e) => debug!("ws proxy error: {}", e), + } + }); + + // Return 101 to client with the backend's upgrade headers + let mut resp = axum::response::Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS); + for (key, value) in &resp_headers { + resp = resp.header(key, value); + } + resp.body(Body::empty()).unwrap() +} diff --git a/src/service_store.rs b/src/service_store.rs new file mode 100644 index 0000000..df34ae6 --- /dev/null +++ b/src/service_store.rs @@ -0,0 +1,52 @@ +use std::collections::HashMap; + +use serde::Serialize; + +#[derive(Clone, Serialize)] +pub struct ServiceEntry { + pub name: String, + pub target_port: u16, +} + +pub struct ServiceStore { + entries: HashMap, +} + +impl Default for ServiceStore { + fn default() -> Self { + Self::new() + } +} + +impl ServiceStore { + pub fn new() -> Self { + ServiceStore { + entries: HashMap::new(), + } + } + + pub fn insert(&mut self, name: &str, target_port: u16) { + let key = name.to_lowercase(); + self.entries.insert( + key.clone(), + ServiceEntry { + name: key, + target_port, + }, + ); + } + + pub fn lookup(&self, name: &str) -> Option<&ServiceEntry> { + self.entries.get(&name.to_lowercase()) + } + + pub fn remove(&mut self, name: &str) -> bool { + self.entries.remove(&name.to_lowercase()).is_some() + } + + pub fn list(&self) -> Vec<&ServiceEntry> { + let mut entries: Vec<_> = self.entries.values().collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + entries + } +} diff --git a/src/system_dns.rs b/src/system_dns.rs index 40ff61a..672fe03 100644 --- a/src/system_dns.rs +++ b/src/system_dns.rs @@ -418,6 +418,15 @@ pub fn uninstall_service() -> Result<(), String> { /// Restart the service (kill process, launchd/systemd auto-restarts with new binary). pub fn restart_service() -> Result<(), String> { + // Show version of the binary that will be running after restart + let version = match std::process::Command::new("/usr/local/bin/numa") + .arg("--version") + .output() + { + Ok(o) => String::from_utf8_lossy(&o.stderr).trim().to_string(), + Err(_) => "unknown".to_string(), + }; + #[cfg(target_os = "macos")] { let output = std::process::Command::new("launchctl") @@ -425,11 +434,10 @@ pub fn restart_service() -> Result<(), String> { .output(); match output { Ok(o) if o.status.success() => { - // Service is loaded — kill the process, launchd restarts it let _ = std::process::Command::new("pkill") .args(["-f", "/usr/local/bin/numa"]) .status(); - eprintln!(" Service restarting (launchd will respawn).\n"); + eprintln!(" Service restarting → {}\n", version); Ok(()) } _ => Err("Service is not installed. Run 'sudo numa service start' first.".to_string()), @@ -438,7 +446,7 @@ pub fn restart_service() -> Result<(), String> { #[cfg(target_os = "linux")] { run_systemctl(&["restart", "numa"])?; - eprintln!(" Service restarted.\n"); + eprintln!(" Service restarted → {}\n", version); Ok(()) } #[cfg(not(any(target_os = "macos", target_os = "linux")))]